sync: update from private repo (9a86f49b)
This commit is contained in:
parent
a67c40d33c
commit
ed3dc5529a
19
CHANGELOG.md
19
CHANGELOG.md
@ -8,6 +8,16 @@ follow semantic versioning.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- A compact Movement Map in chat shows the current step, completed work,
|
||||||
|
retries, and user messages without taking over the conversation. Nodes can
|
||||||
|
be used for navigation, while concise tooltips explain each step
|
||||||
|
(2026-07-12).
|
||||||
|
- Slack channel connections can be managed from Settings, including
|
||||||
|
channel-to-workspace bindings and reliable mention ingestion and replies.
|
||||||
|
Reminders and in-app notifications make scheduled and background work easier
|
||||||
|
to follow (2026-07-12).
|
||||||
|
- Chat now provides per-task model and reasoning-effort selection, plus prompt
|
||||||
|
coaching before sending follow-up instructions (2026-07-11).
|
||||||
- Research pieces (`research` / `sns-research` / `sns-deep-sweep`) now write
|
- Research pieces (`research` / `sns-research` / `sns-deep-sweep`) now write
|
||||||
date-stamped outputs (e.g. `output/report-2026-07-09.md`,
|
date-stamped outputs (e.g. `output/report-2026-07-09.md`,
|
||||||
`output/deepdive/2026-07-09/`) so repeated runs in a persistent workspace
|
`output/deepdive/2026-07-09/`) so repeated runs in a persistent workspace
|
||||||
@ -24,6 +34,15 @@ follow semantic versioning.
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Model selection menus no longer get clipped by the chat viewport, and the
|
||||||
|
“jump to latest” control stays anchored above the composer
|
||||||
|
(2026-07-12).
|
||||||
|
- Pet and star animations now retain stable identities and smoother timing,
|
||||||
|
reducing flicker and visible jumps around worker activity
|
||||||
|
(2026-07-12).
|
||||||
|
- Repeated Slack mention events no longer create duplicate tasks or replies,
|
||||||
|
and connector status and failure feedback are clearer in Settings
|
||||||
|
(2026-07-12).
|
||||||
- Delegate run cards showed "0 tool calls" (and empty tool/file breakdowns)
|
- Delegate run cards showed "0 tool calls" (and empty tool/file breakdowns)
|
||||||
regardless of actual activity; tool events now carry their own run
|
regardless of actual activity; tool events now carry their own run
|
||||||
attribution (2026-07-09).
|
attribution (2026-07-09).
|
||||||
|
|||||||
4
logs/bash-history.jsonl
Normal file
4
logs/bash-history.jsonl
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{"timestamp":"2026-07-12T23:49:58.285Z","command":"pip install pypdf","isError":true,"durationMs":0,"blocked":true}
|
||||||
|
{"timestamp":"2026-07-12T23:49:58.286Z","command":"npm install left-pad","isError":true,"durationMs":1,"blocked":true}
|
||||||
|
{"timestamp":"2026-07-12T23:49:58.287Z","command":"echo hello","isError":false,"durationMs":1,"outputBytes":6}
|
||||||
|
{"timestamp":"2026-07-12T23:49:58.310Z","command":"node -e \"process.stdout.write(String(process.env.MCP_ENCRYPTION_KEY))\"","isError":false,"durationMs":22,"outputBytes":9}
|
||||||
@ -22,6 +22,7 @@ exports[`createCoreServer route registration order > pins the auth-active + Conf
|
|||||||
"USE name=requireAdmin path=^\\/api\\/config\\/?(?=\\/|$)",
|
"USE name=requireAdmin path=^\\/api\\/config\\/?(?=\\/|$)",
|
||||||
"USE name=requireAuth path=^\\/api\\/pieces\\/?(?=\\/|$)",
|
"USE name=requireAuth path=^\\/api\\/pieces\\/?(?=\\/|$)",
|
||||||
"USE name=requireAuth path=^\\/api\\/usage\\/?(?=\\/|$)",
|
"USE name=requireAuth path=^\\/api\\/usage\\/?(?=\\/|$)",
|
||||||
|
"USE name=requireAuth path=^\\/api\\/llm\\/workers\\/?(?=\\/|$)",
|
||||||
"USE name=requireAuth path=^\\/api\\/calendar\\/?(?=\\/|$)",
|
"USE name=requireAuth path=^\\/api\\/calendar\\/?(?=\\/|$)",
|
||||||
"USE name=requireAuth path=^\\/api\\/scheduled-tasks\\/?(?=\\/|$)",
|
"USE name=requireAuth path=^\\/api\\/scheduled-tasks\\/?(?=\\/|$)",
|
||||||
"USE name=jsonParser path=^\\/api\\/admin\\/?(?=\\/|$)",
|
"USE name=jsonParser path=^\\/api\\/admin\\/?(?=\\/|$)",
|
||||||
@ -79,6 +80,7 @@ exports[`createCoreServer route registration order > pins the auth-active + Conf
|
|||||||
"ROUTE post /api/local/tasks/:taskId/regenerate-title",
|
"ROUTE post /api/local/tasks/:taskId/regenerate-title",
|
||||||
"ROUTE post /api/local/tasks/evaluate-prompt",
|
"ROUTE post /api/local/tasks/evaluate-prompt",
|
||||||
"ROUTE get /api/local/tasks/:taskId/stream",
|
"ROUTE get /api/local/tasks/:taskId/stream",
|
||||||
|
"ROUTE get /api/local/tasks/:taskId/movement-history",
|
||||||
"ROUTE get /api/local/tasks/:taskId/files",
|
"ROUTE get /api/local/tasks/:taskId/files",
|
||||||
"ROUTE get /api/local/tasks/:taskId/files/content",
|
"ROUTE get /api/local/tasks/:taskId/files/content",
|
||||||
"ROUTE get /api/local/tasks/:taskId/files/provenance",
|
"ROUTE get /api/local/tasks/:taskId/files/provenance",
|
||||||
@ -92,6 +94,7 @@ exports[`createCoreServer route registration order > pins the auth-active + Conf
|
|||||||
"USE name=router path=^\\/api\\/local\\/tasks\\/?(?=\\/|$)",
|
"USE name=router path=^\\/api\\/local\\/tasks\\/?(?=\\/|$)",
|
||||||
"USE name=router path=^\\/api\\/usage\\/?(?=\\/|$)",
|
"USE name=router path=^\\/api\\/usage\\/?(?=\\/|$)",
|
||||||
"USE name=router path=^\\/api\\/calendar\\/?(?=\\/|$)",
|
"USE name=router path=^\\/api\\/calendar\\/?(?=\\/|$)",
|
||||||
|
"USE name=router path=^\\/api\\/llm\\/workers\\/?(?=\\/|$)",
|
||||||
"ROUTE get /api/local/tasks/:id/subtasks/:jobId/files",
|
"ROUTE get /api/local/tasks/:id/subtasks/:jobId/files",
|
||||||
"ROUTE get /api/local/tasks/:id/subtasks/:jobId/files/*",
|
"ROUTE get /api/local/tasks/:id/subtasks/:jobId/files/*",
|
||||||
"ROUTE get /api/jobs/:jobId",
|
"ROUTE get /api/jobs/:jobId",
|
||||||
@ -198,6 +201,7 @@ exports[`createCoreServer route registration order > pins the no-auth minimal bo
|
|||||||
"ROUTE post /api/local/tasks/:taskId/regenerate-title",
|
"ROUTE post /api/local/tasks/:taskId/regenerate-title",
|
||||||
"ROUTE post /api/local/tasks/evaluate-prompt",
|
"ROUTE post /api/local/tasks/evaluate-prompt",
|
||||||
"ROUTE get /api/local/tasks/:taskId/stream",
|
"ROUTE get /api/local/tasks/:taskId/stream",
|
||||||
|
"ROUTE get /api/local/tasks/:taskId/movement-history",
|
||||||
"ROUTE get /api/local/tasks/:taskId/files",
|
"ROUTE get /api/local/tasks/:taskId/files",
|
||||||
"ROUTE get /api/local/tasks/:taskId/files/content",
|
"ROUTE get /api/local/tasks/:taskId/files/content",
|
||||||
"ROUTE get /api/local/tasks/:taskId/files/provenance",
|
"ROUTE get /api/local/tasks/:taskId/files/provenance",
|
||||||
|
|||||||
@ -20,11 +20,19 @@ import { createUserDelegationsRouter, createAdminDelegationsRouter } from './a2a
|
|||||||
* `a2a.enabled: true` in config.yaml. Registration order relative to the
|
* `a2a.enabled: true` in config.yaml. Registration order relative to the
|
||||||
* other /api/local mounts is preserved by the call site in server.ts.
|
* other /api/local mounts is preserved by the call site in server.ts.
|
||||||
*/
|
*/
|
||||||
|
export interface A2aSubsystemResult {
|
||||||
|
/** The A2A governance limiter, when a2a.enabled. Shared with the chat
|
||||||
|
* connector subsystem (see chat-subsystem.ts) so rate/concurrency/
|
||||||
|
* skill-budget counters are keyed per-grant regardless of entry point. */
|
||||||
|
limiter?: A2aLimiter;
|
||||||
|
}
|
||||||
|
|
||||||
export function setupA2aSubsystem(
|
export function setupA2aSubsystem(
|
||||||
app: express.Express,
|
app: express.Express,
|
||||||
deps: { repo: Repository; authActive: boolean },
|
deps: { repo: Repository; authActive: boolean },
|
||||||
): void {
|
): A2aSubsystemResult {
|
||||||
const { repo, authActive } = deps;
|
const { repo, authActive } = deps;
|
||||||
|
let limiter: A2aLimiter | undefined;
|
||||||
|
|
||||||
// --- A2A OAuth2 Authorization Server ---
|
// --- A2A OAuth2 Authorization Server ---
|
||||||
const a2aCfg = (loadConfig() as any).a2a ?? {};
|
const a2aCfg = (loadConfig() as any).a2a ?? {};
|
||||||
@ -54,6 +62,7 @@ export function setupA2aSubsystem(
|
|||||||
// JSON-RPC と拡張カード(REST)の両経路で 1 grant のレート予算を共有するため、
|
// JSON-RPC と拡張カード(REST)の両経路で 1 grant のレート予算を共有するため、
|
||||||
// limiter は 1 インスタンスをここで生成して両者に渡す。
|
// limiter は 1 インスタンスをここで生成して両者に渡す。
|
||||||
const a2aLimiter = new A2aLimiter(resolveA2aLimits(a2aCfg.limits));
|
const a2aLimiter = new A2aLimiter(resolveA2aLimits(a2aCfg.limits));
|
||||||
|
limiter = a2aLimiter;
|
||||||
app.use(createA2aRouter({ provider, repo, baseUrl: a2aBaseUrl, issuer: String(a2aCfg.issuer), version: '1.0.0', limiter: a2aLimiter }));
|
app.use(createA2aRouter({ provider, repo, baseUrl: a2aBaseUrl, issuer: String(a2aCfg.issuer), version: '1.0.0', limiter: a2aLimiter }));
|
||||||
// JSON-RPC エンドポイント /a2a(SDK DefaultRequestHandler 経由)
|
// JSON-RPC エンドポイント /a2a(SDK DefaultRequestHandler 経由)
|
||||||
// /a2a は /api 配下でないため requireAuth に掛からない。認証は UserBuilder が担う。
|
// /a2a は /api 配下でないため requireAuth に掛からない。認証は UserBuilder が担う。
|
||||||
@ -73,6 +82,8 @@ export function setupA2aSubsystem(
|
|||||||
logger.info('[a2a-oidc] a2a authorization server enabled');
|
logger.info('[a2a-oidc] a2a authorization server enabled');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(`[a2a-oidc] failed to mount a2a authorization server: ${err}`);
|
logger.warn(`[a2a-oidc] failed to mount a2a authorization server: ${err}`);
|
||||||
|
limiter = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { limiter };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -161,6 +161,9 @@ export function mountAuthPipeline(
|
|||||||
// is enforced inside pieces-api.ts handlers.
|
// is enforced inside pieces-api.ts handlers.
|
||||||
app.use('/api/pieces', requireAuth);
|
app.use('/api/pieces', requireAuth);
|
||||||
app.use('/api/usage', requireAuth);
|
app.use('/api/usage', requireAuth);
|
||||||
|
// LLM ワーカー選択 API(タスクへの pin 用): 秘密値は含まないが、未認証の
|
||||||
|
// 列挙は不要なので他の /api/* と同様 requireAuth で揃える。
|
||||||
|
app.use('/api/llm/workers', requireAuth);
|
||||||
// 横断カレンダー: 認証時はログイン必須。可視スペースの絞り込みは
|
// 横断カレンダー: 認証時はログイン必須。可視スペースの絞り込みは
|
||||||
// Repository.getCrossSpaceCalendarMonth が viewer 単位で行う。
|
// Repository.getCrossSpaceCalendarMonth が viewer 単位で行う。
|
||||||
app.use('/api/calendar', requireAuth);
|
app.use('/api/calendar', requireAuth);
|
||||||
|
|||||||
270
src/bridge/chat/chat-connector-bindings-api.test.ts
Normal file
270
src/bridge/chat/chat-connector-bindings-api.test.ts
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { randomBytes, randomUUID } from 'crypto';
|
||||||
|
import { Repository } from '../../db/repository.js';
|
||||||
|
import { createChatConnectorBindingsAdminRouter } from './chat-connector-bindings-api.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal admin gate mirroring requireAdmin's observable contract
|
||||||
|
* (401 unauthenticated / 403 non-admin / next() for admin), following the
|
||||||
|
* same pattern as mcp-api.test.ts and dashboard-api.auth-guard.test.ts —
|
||||||
|
* real requireAdmin depends on passport's req.isAuthenticated() which needs
|
||||||
|
* a full session harness this router-level test doesn't need.
|
||||||
|
*/
|
||||||
|
function adminGate(user: { id: string; role: 'admin' | 'user' } | null): express.RequestHandler {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (!user) { res.status(401).json({ error: 'Unauthorized' }); return; }
|
||||||
|
(req as any).user = user;
|
||||||
|
if (user.role !== 'admin') { res.status(403).json({ error: 'Forbidden' }); return; }
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appWith(repo: Repository, user: { id: string; role: 'admin' | 'user' } | null = { id: 'admin1', role: 'admin' }) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/admin/chat/bindings', adminGate(user), createChatConnectorBindingsAdminRouter(repo, true));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('chat connector bindings admin API', () => {
|
||||||
|
let repo: Repository;
|
||||||
|
let ownerId = '';
|
||||||
|
let spaceId = '';
|
||||||
|
let clientId = '';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
process.env.MCP_ENCRYPTION_KEY = randomBytes(32).toString('hex');
|
||||||
|
repo = new Repository(':memory:');
|
||||||
|
const owner = repo.createUser({ email: 'owner@example.com', name: 'Owner', role: 'user', status: 'active' });
|
||||||
|
ownerId = owner.id;
|
||||||
|
const space = await repo.createSpace({ kind: 'case', title: 'Case', ownerId, visibility: 'private' });
|
||||||
|
spaceId = space.id;
|
||||||
|
clientId = `a2a_${randomUUID()}`;
|
||||||
|
repo.createA2aClient({
|
||||||
|
clientId, name: 'Test Slack App', redirectUris: ['https://example.com/cb'],
|
||||||
|
secretHash: null, tokenEndpointAuthMethod: 'none', agentCardUrl: null, issuer: null,
|
||||||
|
keyFingerprint: null, status: 'active', createdBy: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => { repo.close(); });
|
||||||
|
|
||||||
|
function seedDelegation(opts: { grantedSpaceIds?: string[]; revokedAt?: string | null; grantId?: string | null } = {}): string {
|
||||||
|
const id = randomUUID();
|
||||||
|
repo.createA2aDelegation({
|
||||||
|
id, userId: ownerId, clientId,
|
||||||
|
grantId: opts.grantId === undefined ? randomUUID() : opts.grantId,
|
||||||
|
grantedSpaceIds: opts.grantedSpaceIds ?? [spaceId],
|
||||||
|
grantedSkills: ['chat'],
|
||||||
|
audience: null, expiresAt: null, revokedAt: opts.revokedAt ?? null,
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validBody = (delegationId: string, overrides: Record<string, unknown> = {}) => ({
|
||||||
|
platform: 'slack',
|
||||||
|
externalWorkspaceId: 'T123',
|
||||||
|
externalChannelId: 'C456',
|
||||||
|
a2aDelegationId: delegationId,
|
||||||
|
botCredentials: { signingSecret: 'sec', botToken: 'xoxb-test' },
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── admin gate ─────────────────────────────────────────────────────────
|
||||||
|
it('rejects unauthenticated requests with 401', async () => {
|
||||||
|
const app = appWith(repo, null);
|
||||||
|
const res = await request(app).get('/api/admin/chat/bindings');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-admin authenticated requests with 403', async () => {
|
||||||
|
const app = appWith(repo, { id: 'u1', role: 'user' });
|
||||||
|
const res = await request(app).get('/api/admin/chat/bindings');
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows admin requests through to the router', async () => {
|
||||||
|
const app = appWith(repo, { id: 'admin1', role: 'admin' });
|
||||||
|
const res = await request(app).get('/api/admin/chat/bindings');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.bindings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── create ─────────────────────────────────────────────────────────────
|
||||||
|
it('creates a binding for a delegation granting exactly one space', async () => {
|
||||||
|
const app = appWith(repo);
|
||||||
|
const delegationId = seedDelegation();
|
||||||
|
const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId));
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.spaceId).toBe(spaceId);
|
||||||
|
expect(res.body.a2aDelegationId).toBe(delegationId);
|
||||||
|
expect(res.body.status).toBe('active');
|
||||||
|
// Credentials must never appear in the response.
|
||||||
|
expect(res.body.botCredentialsEnc).toBeUndefined();
|
||||||
|
expect(res.body.botCredentials).toBeUndefined();
|
||||||
|
expect(JSON.stringify(res.body)).not.toContain('xoxb-test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a delegation granting zero spaces (1 delegation = 1 space enforcement)', async () => {
|
||||||
|
const app = appWith(repo);
|
||||||
|
const delegationId = seedDelegation({ grantedSpaceIds: [] });
|
||||||
|
const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/exactly one space/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a delegation granting more than one space (1 delegation = 1 space enforcement)', async () => {
|
||||||
|
const app = appWith(repo);
|
||||||
|
const space2 = await repo.createSpace({ kind: 'case', title: 'Case 2', ownerId, visibility: 'private' });
|
||||||
|
const delegationId = seedDelegation({ grantedSpaceIds: [spaceId, space2.id] });
|
||||||
|
const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/exactly one space/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives spaceId from the delegation, ignoring any client-supplied spaceId', async () => {
|
||||||
|
const app = appWith(repo);
|
||||||
|
const delegationId = seedDelegation();
|
||||||
|
const res = await request(app).post('/api/admin/chat/bindings')
|
||||||
|
.send(validBody(delegationId, { spaceId: 'attacker-supplied-space' }));
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.spaceId).toBe(spaceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a revoked delegation', async () => {
|
||||||
|
const app = appWith(repo);
|
||||||
|
const delegationId = seedDelegation({ revokedAt: new Date().toISOString() });
|
||||||
|
const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a delegation with no grant_id', async () => {
|
||||||
|
const app = appWith(repo);
|
||||||
|
const delegationId = seedDelegation({ grantId: null });
|
||||||
|
const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown delegation id', async () => {
|
||||||
|
const app = appWith(repo);
|
||||||
|
const res = await request(app).post('/api/admin/chat/bindings').send(validBody('nonexistent'));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown platform', async () => {
|
||||||
|
const app = appWith(repo);
|
||||||
|
const delegationId = seedDelegation();
|
||||||
|
const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId, { platform: 'discord' }));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a request missing botCredentials', async () => {
|
||||||
|
const app = appWith(repo);
|
||||||
|
const delegationId = seedDelegation();
|
||||||
|
const res = await request(app).post('/api/admin/chat/bindings')
|
||||||
|
.send(validBody(delegationId, { botCredentials: undefined }));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a duplicate (platform, team, channel) binding with 409', async () => {
|
||||||
|
const app = appWith(repo);
|
||||||
|
const delegationId1 = seedDelegation();
|
||||||
|
await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId1));
|
||||||
|
const delegationId2 = seedDelegation();
|
||||||
|
const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId2));
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets created_by to the authenticated admin, not any client-supplied value', async () => {
|
||||||
|
const app = appWith(repo, { id: 'admin-xyz', role: 'admin' });
|
||||||
|
const delegationId = seedDelegation();
|
||||||
|
const res = await request(app).post('/api/admin/chat/bindings')
|
||||||
|
.send(validBody(delegationId, { createdBy: 'attacker' }));
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.createdBy).toBe('admin-xyz');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── read ───────────────────────────────────────────────────────────────
|
||||||
|
it('GET /:id returns 404 for an unknown id', async () => {
|
||||||
|
const app = appWith(repo);
|
||||||
|
const res = await request(app).get('/api/admin/chat/bindings/nonexistent');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET list never includes bot_credentials_enc', async () => {
|
||||||
|
const app = appWith(repo);
|
||||||
|
const delegationId = seedDelegation();
|
||||||
|
await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId));
|
||||||
|
const res = await request(app).get('/api/admin/chat/bindings');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.bindings).toHaveLength(1);
|
||||||
|
expect(JSON.stringify(res.body)).not.toContain('xoxb-test');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── update ─────────────────────────────────────────────────────────────
|
||||||
|
it('PATCH disables and re-enables a binding', async () => {
|
||||||
|
const app = appWith(repo);
|
||||||
|
const delegationId = seedDelegation();
|
||||||
|
const created = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId));
|
||||||
|
const id = created.body.id;
|
||||||
|
|
||||||
|
const disabled = await request(app).patch(`/api/admin/chat/bindings/${id}`).send({ status: 'disabled' });
|
||||||
|
expect(disabled.status).toBe(200);
|
||||||
|
expect(disabled.body.status).toBe('disabled');
|
||||||
|
|
||||||
|
const enabled = await request(app).patch(`/api/admin/chat/bindings/${id}`).send({ status: 'active' });
|
||||||
|
expect(enabled.status).toBe(200);
|
||||||
|
expect(enabled.body.status).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH re-encrypts credentials on re-entry and never echoes them back', async () => {
|
||||||
|
const app = appWith(repo);
|
||||||
|
const delegationId = seedDelegation();
|
||||||
|
const created = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId));
|
||||||
|
const id = created.body.id;
|
||||||
|
|
||||||
|
const res = await request(app).patch(`/api/admin/chat/bindings/${id}`)
|
||||||
|
.send({ botCredentials: { signingSecret: 'new-sec', botToken: 'xoxb-new-token' } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(JSON.stringify(res.body)).not.toContain('xoxb-new-token');
|
||||||
|
// Verify the stored ciphertext actually changed.
|
||||||
|
const stored = repo.getChatConnectorBindingById(id)!;
|
||||||
|
const key = Buffer.from(process.env.MCP_ENCRYPTION_KEY!, 'hex');
|
||||||
|
const { decrypt } = await import('../../mcp/crypto.js');
|
||||||
|
expect(JSON.parse(decrypt(stored.botCredentialsEnc, key)).botToken).toBe('xoxb-new-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH with no updatable fields returns 400', async () => {
|
||||||
|
const app = appWith(repo);
|
||||||
|
const delegationId = seedDelegation();
|
||||||
|
const created = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId));
|
||||||
|
const res = await request(app).patch(`/api/admin/chat/bindings/${created.body.id}`).send({});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH on an unknown id returns 404', async () => {
|
||||||
|
const app = appWith(repo);
|
||||||
|
const res = await request(app).patch('/api/admin/chat/bindings/nonexistent').send({ status: 'disabled' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── delete ─────────────────────────────────────────────────────────────
|
||||||
|
it('DELETE removes a binding', async () => {
|
||||||
|
const app = appWith(repo);
|
||||||
|
const delegationId = seedDelegation();
|
||||||
|
const created = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId));
|
||||||
|
const id = created.body.id;
|
||||||
|
const res = await request(app).delete(`/api/admin/chat/bindings/${id}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(repo.getChatConnectorBindingById(id)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE on an unknown id returns 404', async () => {
|
||||||
|
const app = appWith(repo);
|
||||||
|
const res = await request(app).delete('/api/admin/chat/bindings/nonexistent');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
197
src/bridge/chat/chat-connector-bindings-api.ts
Normal file
197
src/bridge/chat/chat-connector-bindings-api.ts
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* chat-connector-bindings-api: admin CRUD for chat_connector_bindings
|
||||||
|
* (issue #801, PR1 — Slack MVP). No UI wizard yet; this is the manual-seed
|
||||||
|
* surface an admin (or a script) calls directly. requireAdmin is applied by
|
||||||
|
* the mount call site (see chat-subsystem.ts), mirroring a2a-clients-admin-api.ts.
|
||||||
|
*
|
||||||
|
* SECURITY:
|
||||||
|
* - The response DTO (toPublicBinding) NEVER includes bot_credentials_enc
|
||||||
|
* or a decrypted credential. Once saved, credentials are write-only —
|
||||||
|
* re-entering them means a PATCH with a fresh { botCredentials } object.
|
||||||
|
* - 1 delegation = 1 space is enforced at create time: the referenced
|
||||||
|
* a2a_delegation must have exactly one granted space id, and that id
|
||||||
|
* (not any client-supplied value) becomes the binding's space_id. This
|
||||||
|
* keeps the chat channel's effective space deterministic (skill-router.ts
|
||||||
|
* always resolves `effectiveSpaceIds[0]`).
|
||||||
|
* - "acting user" for job execution is always the delegation's own user_id
|
||||||
|
* (the person who consented via the A2A OAuth flow) — never client input.
|
||||||
|
* `created_by` on the binding row is the authenticated admin, also never
|
||||||
|
* client input.
|
||||||
|
*/
|
||||||
|
import { Router, type Request, type Response } from 'express';
|
||||||
|
import type { Repository, ChatConnectorBindingRecord } from '../../db/repository.js';
|
||||||
|
import { isDelegationLive } from '../a2a/delegation.js';
|
||||||
|
import { encrypt, loadKeyFromEnv } from '../../mcp/crypto.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
|
||||||
|
const KNOWN_PLATFORMS = ['slack'] as const;
|
||||||
|
const MAX_ID_LENGTH = 256;
|
||||||
|
|
||||||
|
function toPublicBinding(b: ChatConnectorBindingRecord) {
|
||||||
|
return {
|
||||||
|
id: b.id,
|
||||||
|
platform: b.platform,
|
||||||
|
externalWorkspaceId: b.externalWorkspaceId,
|
||||||
|
externalChannelId: b.externalChannelId,
|
||||||
|
spaceId: b.spaceId,
|
||||||
|
a2aClientId: b.a2aClientId,
|
||||||
|
a2aDelegationId: b.a2aDelegationId,
|
||||||
|
status: b.status,
|
||||||
|
createdBy: b.createdBy,
|
||||||
|
createdAt: b.createdAt,
|
||||||
|
updatedAt: b.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNonEmptyString(v: unknown, max = MAX_ID_LENGTH): v is string {
|
||||||
|
return typeof v === 'string' && v.trim().length > 0 && v.length <= max;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChatConnectorBindingsAdminRouter(repo: Repository, _authActive: boolean): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', (_req: Request, res: Response) => {
|
||||||
|
res.json({ bindings: repo.listChatConnectorBindings().map(toPublicBinding) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', (req: Request, res: Response) => {
|
||||||
|
const binding = repo.getChatConnectorBindingById(req.params.id);
|
||||||
|
if (!binding) { res.status(404).json({ error: 'not found' }); return; }
|
||||||
|
res.json(toPublicBinding(binding));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', (req: Request, res: Response) => {
|
||||||
|
const body = req.body ?? {};
|
||||||
|
const rawPlatform = body.platform;
|
||||||
|
if (typeof rawPlatform !== 'string' || !(KNOWN_PLATFORMS as readonly string[]).includes(rawPlatform)) {
|
||||||
|
res.status(400).json({ error: `platform must be one of: ${KNOWN_PLATFORMS.join(', ')}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const platform = rawPlatform as (typeof KNOWN_PLATFORMS)[number];
|
||||||
|
if (!isNonEmptyString(body.externalWorkspaceId) || !isNonEmptyString(body.externalChannelId)) {
|
||||||
|
res.status(400).json({ error: 'externalWorkspaceId and externalChannelId are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isNonEmptyString(body.a2aDelegationId)) {
|
||||||
|
res.status(400).json({ error: 'a2aDelegationId is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const creds = body.botCredentials;
|
||||||
|
if (!creds || !isNonEmptyString(creds.signingSecret) || !isNonEmptyString(creds.botToken)) {
|
||||||
|
res.status(400).json({ error: 'botCredentials.signingSecret and botCredentials.botToken are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delegation = repo.getA2aDelegationById(body.a2aDelegationId);
|
||||||
|
if (!delegation || !delegation.grantId) {
|
||||||
|
res.status(400).json({ error: 'a2aDelegationId does not reference a live delegation with an active grant' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isDelegationLive(delegation, new Date().toISOString())) {
|
||||||
|
res.status(400).json({ error: 'delegation is expired or revoked' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 1 delegation = 1 space, enforced (not client-suppliable).
|
||||||
|
if (delegation.grantedSpaceIds.length !== 1) {
|
||||||
|
res.status(400).json({ error: 'delegation must grant exactly one space to back a chat binding' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const spaceId = delegation.grantedSpaceIds[0];
|
||||||
|
|
||||||
|
let botCredentialsEnc: Buffer;
|
||||||
|
try {
|
||||||
|
botCredentialsEnc = encrypt(JSON.stringify({ signingSecret: creds.signingSecret, botToken: creds.botToken }), loadKeyFromEnv());
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[chat-connector-bindings-api] encryption unavailable: ${(err as Error).message}`);
|
||||||
|
res.status(503).json({ error: 'credential encryption is not configured on this server' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = (req.user as Express.User | undefined)?.id ?? null;
|
||||||
|
try {
|
||||||
|
const created = repo.createChatConnectorBinding({
|
||||||
|
platform,
|
||||||
|
externalWorkspaceId: body.externalWorkspaceId,
|
||||||
|
externalChannelId: body.externalChannelId,
|
||||||
|
spaceId,
|
||||||
|
a2aClientId: delegation.clientId,
|
||||||
|
a2aDelegationId: delegation.id,
|
||||||
|
a2aGrantId: delegation.grantId,
|
||||||
|
botCredentialsEnc,
|
||||||
|
createdBy: admin,
|
||||||
|
});
|
||||||
|
logger.info(`[chat-connector-bindings-api] created binding=${created.id} platform=${platform} space=${spaceId} by=${admin}`);
|
||||||
|
res.status(201).json(toPublicBinding(created));
|
||||||
|
} catch (err) {
|
||||||
|
// UNIQUE constraint on (platform, external_workspace_id, external_channel_id)
|
||||||
|
const msg = (err as Error).message ?? '';
|
||||||
|
if (/UNIQUE constraint failed/.test(msg)) {
|
||||||
|
res.status(409).json({ error: 'a binding already exists for this platform/workspace/channel' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error(`[chat-connector-bindings-api] POST failed: ${msg}`);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH — update status (enable/disable) and/or re-enter credentials.
|
||||||
|
router.patch('/:id', (req: Request, res: Response) => {
|
||||||
|
const binding = repo.getChatConnectorBindingById(req.params.id);
|
||||||
|
if (!binding) { res.status(404).json({ error: 'not found' }); return; }
|
||||||
|
|
||||||
|
const body = req.body ?? {};
|
||||||
|
const patch: Parameters<typeof repo.updateChatConnectorBinding>[1] = {};
|
||||||
|
|
||||||
|
if (body.status !== undefined) {
|
||||||
|
if (body.status !== 'active' && body.status !== 'disabled') {
|
||||||
|
res.status(400).json({ error: "status must be 'active' or 'disabled'" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
patch.status = body.status;
|
||||||
|
}
|
||||||
|
if (body.botCredentials !== undefined) {
|
||||||
|
const creds = body.botCredentials;
|
||||||
|
if (!creds || !isNonEmptyString(creds.signingSecret) || !isNonEmptyString(creds.botToken)) {
|
||||||
|
res.status(400).json({ error: 'botCredentials.signingSecret and botCredentials.botToken are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
patch.botCredentialsEnc = encrypt(JSON.stringify({ signingSecret: creds.signingSecret, botToken: creds.botToken }), loadKeyFromEnv());
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[chat-connector-bindings-api] encryption unavailable: ${(err as Error).message}`);
|
||||||
|
res.status(503).json({ error: 'credential encryption is not configured on this server' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(patch).length === 0) {
|
||||||
|
res.status(400).json({ error: 'no updatable fields provided' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = repo.updateChatConnectorBinding(binding.id, patch);
|
||||||
|
if (!updated) { res.status(404).json({ error: 'not found' }); return; }
|
||||||
|
logger.info(`[chat-connector-bindings-api] updated binding=${binding.id} fields=${Object.keys(patch).join(',')}`);
|
||||||
|
res.json(toPublicBinding(updated));
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[chat-connector-bindings-api] PATCH failed binding=${binding.id}: ${(err as Error).message}`);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', (req: Request, res: Response) => {
|
||||||
|
const binding = repo.getChatConnectorBindingById(req.params.id);
|
||||||
|
if (!binding) { res.status(404).json({ error: 'not found' }); return; }
|
||||||
|
try {
|
||||||
|
repo.deleteChatConnectorBinding(binding.id);
|
||||||
|
logger.info(`[chat-connector-bindings-api] deleted binding=${binding.id}`);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[chat-connector-bindings-api] DELETE failed binding=${binding.id}: ${(err as Error).message}`);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
290
src/bridge/chat/chat-connector-service.test.ts
Normal file
290
src/bridge/chat/chat-connector-service.test.ts
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
/**
|
||||||
|
* chat-connector-service tests. Uses a real (temp-file) Repository — matching
|
||||||
|
* webhook-service.test.ts's convention — plus mocked executor/chat-client
|
||||||
|
* factories so we exercise binding resolution, delegation liveness,
|
||||||
|
* governance, and reply-building without a real MaestroA2aExecutor job run
|
||||||
|
* (that path is covered by executor.test.ts).
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { randomBytes, randomUUID } from 'crypto';
|
||||||
|
import { Repository } from '../../db/repository.js';
|
||||||
|
import type { ChatConnectorBindingRecord } from '../../db/repository.js';
|
||||||
|
import { encrypt } from '../../mcp/crypto.js';
|
||||||
|
import { handleSlackMention, type ChatConnectorServiceDeps } from './chat-connector-service.js';
|
||||||
|
import type { ChatMentionEvent } from './chat-connector-types.js';
|
||||||
|
import { A2aLimiter, DEFAULT_A2A_LIMITS } from '../a2a/limiter.js';
|
||||||
|
|
||||||
|
describe('chat-connector-service: handleSlackMention', () => {
|
||||||
|
let tempDir = '';
|
||||||
|
let repo: Repository;
|
||||||
|
let ownerId = '';
|
||||||
|
let spaceId = '';
|
||||||
|
let clientId = '';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
process.env.MCP_ENCRYPTION_KEY = randomBytes(32).toString('hex');
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), 'maestro-chat-svc-'));
|
||||||
|
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||||
|
const owner = repo.createUser({ email: 'owner@example.com', name: 'Owner', role: 'user', status: 'active' });
|
||||||
|
ownerId = owner.id;
|
||||||
|
const space = await repo.createSpace({ kind: 'case', title: 'Case', ownerId, visibility: 'private' });
|
||||||
|
spaceId = space.id;
|
||||||
|
clientId = `a2a_${randomUUID()}`;
|
||||||
|
repo.createA2aClient({
|
||||||
|
clientId, name: 'Test Slack App', redirectUris: ['https://example.com/cb'],
|
||||||
|
secretHash: null, tokenEndpointAuthMethod: 'none', agentCardUrl: null, issuer: null,
|
||||||
|
keyFingerprint: null, status: 'active', createdBy: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
repo.close();
|
||||||
|
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function seedDelegation(opts: { grantId?: string | null; grantedSpaceIds?: string[]; revokedAt?: string | null; expiresAt?: string | null } = {}): string {
|
||||||
|
const id = randomUUID();
|
||||||
|
repo.createA2aDelegation({
|
||||||
|
id, userId: ownerId, clientId,
|
||||||
|
grantId: opts.grantId === undefined ? randomUUID() : opts.grantId,
|
||||||
|
grantedSpaceIds: opts.grantedSpaceIds ?? [spaceId],
|
||||||
|
grantedSkills: ['chat'],
|
||||||
|
audience: null,
|
||||||
|
expiresAt: opts.expiresAt ?? null,
|
||||||
|
revokedAt: opts.revokedAt ?? null,
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encryptCreds(): Buffer {
|
||||||
|
const key = Buffer.from(process.env.MCP_ENCRYPTION_KEY!, 'hex');
|
||||||
|
return encrypt(JSON.stringify({ signingSecret: 'sec', botToken: 'xoxb-test' }), key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedBinding(delegationId: string, grantId: string, opts: { status?: 'active' | 'disabled'; team?: string; channel?: string } = {}): ChatConnectorBindingRecord {
|
||||||
|
return repo.createChatConnectorBinding({
|
||||||
|
platform: 'slack',
|
||||||
|
externalWorkspaceId: opts.team ?? 'T123',
|
||||||
|
externalChannelId: opts.channel ?? 'C456',
|
||||||
|
spaceId,
|
||||||
|
a2aClientId: clientId,
|
||||||
|
a2aDelegationId: delegationId,
|
||||||
|
a2aGrantId: grantId,
|
||||||
|
botCredentialsEnc: encryptCreds(),
|
||||||
|
createdBy: ownerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEvent(overrides: Partial<ChatMentionEvent> = {}): ChatMentionEvent {
|
||||||
|
return {
|
||||||
|
platform: 'slack',
|
||||||
|
externalWorkspaceId: 'T123',
|
||||||
|
externalChannelId: 'C456',
|
||||||
|
text: 'summarize the thread',
|
||||||
|
messageTs: '1700000000.000100',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeChatClientFactory() {
|
||||||
|
const postMessage = vi.fn().mockResolvedValue(true);
|
||||||
|
const factory = () => ({ postMessage });
|
||||||
|
return { factory, postMessage };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('ignores a mention when no active binding exists (fail-closed, no reply)', async () => {
|
||||||
|
const { factory, postMessage } = makeChatClientFactory();
|
||||||
|
const executorFactory = vi.fn();
|
||||||
|
await handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory } as unknown as ChatConnectorServiceDeps);
|
||||||
|
expect(postMessage).not.toHaveBeenCalled();
|
||||||
|
expect(executorFactory).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a mention when the binding is disabled', async () => {
|
||||||
|
const grantId = randomUUID();
|
||||||
|
const delegationId = seedDelegation({ grantId });
|
||||||
|
seedBinding(delegationId, grantId, { status: 'active' });
|
||||||
|
// Immediately disable it.
|
||||||
|
const binding = repo.findActiveChatConnectorBinding('slack', 'T123', 'C456')!;
|
||||||
|
repo.updateChatConnectorBinding(binding.id, { status: 'disabled' });
|
||||||
|
|
||||||
|
const { factory, postMessage } = makeChatClientFactory();
|
||||||
|
const executorFactory = vi.fn();
|
||||||
|
await handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory } as unknown as ChatConnectorServiceDeps);
|
||||||
|
expect(postMessage).not.toHaveBeenCalled();
|
||||||
|
expect(executorFactory).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a mention when the delegation has been revoked (fail-closed)', async () => {
|
||||||
|
const grantId = randomUUID();
|
||||||
|
const delegationId = seedDelegation({ grantId, revokedAt: new Date().toISOString() });
|
||||||
|
seedBinding(delegationId, grantId);
|
||||||
|
|
||||||
|
const { factory, postMessage } = makeChatClientFactory();
|
||||||
|
const executorFactory = vi.fn();
|
||||||
|
await handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory } as unknown as ChatConnectorServiceDeps);
|
||||||
|
expect(postMessage).not.toHaveBeenCalled();
|
||||||
|
expect(executorFactory).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a mention when the delegation has expired (fail-closed)', async () => {
|
||||||
|
const grantId = randomUUID();
|
||||||
|
const delegationId = seedDelegation({ grantId, expiresAt: new Date(Date.now() - 60_000).toISOString() });
|
||||||
|
seedBinding(delegationId, grantId);
|
||||||
|
|
||||||
|
const { factory, postMessage } = makeChatClientFactory();
|
||||||
|
const executorFactory = vi.fn();
|
||||||
|
await handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory } as unknown as ChatConnectorServiceDeps);
|
||||||
|
expect(postMessage).not.toHaveBeenCalled();
|
||||||
|
expect(executorFactory).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a mention with an empty (whitespace-only) text', async () => {
|
||||||
|
const grantId = randomUUID();
|
||||||
|
const delegationId = seedDelegation({ grantId });
|
||||||
|
seedBinding(delegationId, grantId);
|
||||||
|
|
||||||
|
const { factory, postMessage } = makeChatClientFactory();
|
||||||
|
const executorFactory = vi.fn();
|
||||||
|
await handleSlackMention(makeEvent({ text: ' ' }), { repo, chatClientFactory: factory, executorFactory } as unknown as ChatConnectorServiceDeps);
|
||||||
|
expect(postMessage).not.toHaveBeenCalled();
|
||||||
|
expect(executorFactory).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs a mention through the executor and replies with the local task result comment + link', async () => {
|
||||||
|
const grantId = randomUUID();
|
||||||
|
const delegationId = seedDelegation({ grantId });
|
||||||
|
seedBinding(delegationId, grantId);
|
||||||
|
|
||||||
|
const localTask = await repo.createLocalTask({
|
||||||
|
title: 'from slack', body: 'summarize the thread', pieceName: 'chat',
|
||||||
|
ownerId, spaceId, visibility: 'private', workspaceMode: 'persistent',
|
||||||
|
});
|
||||||
|
await repo.addLocalTaskComment(localTask.id, 'agent', '✅ 完了\n\nHere is the summary.', 'result');
|
||||||
|
|
||||||
|
const { factory, postMessage } = makeChatClientFactory();
|
||||||
|
const executeSpy = vi.fn(async (_ctx: unknown, bus: any) => {
|
||||||
|
bus.publish({
|
||||||
|
kind: 'task', id: 'a2a-task-1', contextId: 'ctx-1',
|
||||||
|
status: { state: 'submitted', timestamp: new Date().toISOString() },
|
||||||
|
metadata: { maestroLocalTaskId: localTask.id, maestroJobId: 'job-1' },
|
||||||
|
});
|
||||||
|
bus.publish({
|
||||||
|
kind: 'status-update', taskId: 'a2a-task-1', contextId: 'ctx-1',
|
||||||
|
status: { state: 'completed', timestamp: new Date().toISOString() }, final: true,
|
||||||
|
});
|
||||||
|
bus.finished();
|
||||||
|
});
|
||||||
|
const executorFactory = vi.fn(() => ({ execute: executeSpy }));
|
||||||
|
|
||||||
|
await handleSlackMention(makeEvent(), {
|
||||||
|
repo, chatClientFactory: factory, executorFactory,
|
||||||
|
publicBaseUrl: 'https://maestro.example.com',
|
||||||
|
} as unknown as ChatConnectorServiceDeps);
|
||||||
|
|
||||||
|
expect(executeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
// requestContext carries the in-process A2A principal built from the delegation row.
|
||||||
|
const [ctxArg] = executeSpy.mock.calls[0];
|
||||||
|
expect((ctxArg as any).context.user.a2aPrincipal.actingUserId).toBe(ownerId);
|
||||||
|
expect((ctxArg as any).context.user.a2aPrincipal.grantId).toBe(grantId);
|
||||||
|
expect((ctxArg as any).userMessage.parts[0].text).toBe('summarize the thread');
|
||||||
|
|
||||||
|
expect(postMessage).toHaveBeenCalledTimes(1);
|
||||||
|
const [replyArgs] = postMessage.mock.calls[0];
|
||||||
|
expect(replyArgs.channel).toBe('C456');
|
||||||
|
expect(replyArgs.threadTs).toBe('1700000000.000100');
|
||||||
|
expect(replyArgs.text).toContain('Here is the summary.');
|
||||||
|
expect(replyArgs.text).toContain(`space=${spaceId}`);
|
||||||
|
expect(replyArgs.text).toContain(`chat=${localTask.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replies with the task-detail link using threadTs (not messageTs) when the mention was inside a thread', async () => {
|
||||||
|
const grantId = randomUUID();
|
||||||
|
const delegationId = seedDelegation({ grantId });
|
||||||
|
seedBinding(delegationId, grantId);
|
||||||
|
const localTask = await repo.createLocalTask({
|
||||||
|
title: 't', body: 'x', pieceName: 'chat', ownerId, spaceId, visibility: 'private', workspaceMode: 'persistent',
|
||||||
|
});
|
||||||
|
await repo.addLocalTaskComment(localTask.id, 'agent', '✅ 完了\n\ndone', 'result');
|
||||||
|
|
||||||
|
const { factory, postMessage } = makeChatClientFactory();
|
||||||
|
const executorFactory = vi.fn(() => ({
|
||||||
|
execute: async (_ctx: unknown, bus: any) => {
|
||||||
|
bus.publish({ kind: 'task', metadata: { maestroLocalTaskId: localTask.id }, status: { state: 'submitted' } });
|
||||||
|
bus.publish({ kind: 'status-update', status: { state: 'completed' }, final: true });
|
||||||
|
bus.finished();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
await handleSlackMention(makeEvent({ threadTs: '1699999999.000001', messageTs: '1700000000.000100' }), {
|
||||||
|
repo, chatClientFactory: factory, executorFactory,
|
||||||
|
} as unknown as ChatConnectorServiceDeps);
|
||||||
|
|
||||||
|
expect(postMessage.mock.calls[0][0].threadTs).toBe('1699999999.000001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('governance: rejects an oversized mention without invoking the executor', async () => {
|
||||||
|
const grantId = randomUUID();
|
||||||
|
const delegationId = seedDelegation({ grantId });
|
||||||
|
seedBinding(delegationId, grantId);
|
||||||
|
const limiter = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxPayloadBytes: 8 });
|
||||||
|
|
||||||
|
const { factory, postMessage } = makeChatClientFactory();
|
||||||
|
const executorFactory = vi.fn();
|
||||||
|
await handleSlackMention(makeEvent({ text: 'this text is definitely longer than 8 bytes' }), {
|
||||||
|
repo, chatClientFactory: factory, executorFactory, limiter,
|
||||||
|
} as unknown as ChatConnectorServiceDeps);
|
||||||
|
|
||||||
|
expect(executorFactory).not.toHaveBeenCalled();
|
||||||
|
expect(postMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(postMessage.mock.calls[0][0].text).toContain('長すぎます');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('governance: reuses the shared A2aLimiter so a second mention from the same delegation within the rate window is rejected', async () => {
|
||||||
|
const grantId = randomUUID();
|
||||||
|
const delegationId = seedDelegation({ grantId });
|
||||||
|
seedBinding(delegationId, grantId);
|
||||||
|
// ratePerMinute: 1 — the very next call from the same grant must be throttled.
|
||||||
|
const limiter = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, ratePerMinute: 1 });
|
||||||
|
|
||||||
|
const localTask = await repo.createLocalTask({
|
||||||
|
title: 't', body: 'x', pieceName: 'chat', ownerId, spaceId, visibility: 'private', workspaceMode: 'persistent',
|
||||||
|
});
|
||||||
|
await repo.addLocalTaskComment(localTask.id, 'agent', '✅ done', 'result');
|
||||||
|
const executorFactory = vi.fn(() => ({
|
||||||
|
execute: async (_ctx: unknown, bus: any) => {
|
||||||
|
bus.publish({ kind: 'task', metadata: { maestroLocalTaskId: localTask.id }, status: { state: 'submitted' } });
|
||||||
|
bus.publish({ kind: 'status-update', status: { state: 'completed' }, final: true });
|
||||||
|
bus.finished();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const { factory, postMessage } = makeChatClientFactory();
|
||||||
|
|
||||||
|
await handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory, limiter } as unknown as ChatConnectorServiceDeps);
|
||||||
|
await handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory, limiter } as unknown as ChatConnectorServiceDeps);
|
||||||
|
|
||||||
|
expect(executorFactory).toHaveBeenCalledTimes(1); // second call was rate-limited before reaching the executor
|
||||||
|
expect(postMessage).toHaveBeenCalledTimes(2);
|
||||||
|
expect(postMessage.mock.calls[1][0].text).toContain('多すぎます');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed (no reply, no throw) when bot credentials cannot be decrypted', async () => {
|
||||||
|
const grantId = randomUUID();
|
||||||
|
const delegationId = seedDelegation({ grantId });
|
||||||
|
seedBinding(delegationId, grantId);
|
||||||
|
// Swap the encryption key after the binding was created — decrypt() will now fail (auth tag mismatch).
|
||||||
|
process.env.MCP_ENCRYPTION_KEY = randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
const { factory, postMessage } = makeChatClientFactory();
|
||||||
|
const executorFactory = vi.fn();
|
||||||
|
await expect(
|
||||||
|
handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory } as unknown as ChatConnectorServiceDeps),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
expect(executorFactory).not.toHaveBeenCalled();
|
||||||
|
expect(postMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
266
src/bridge/chat/chat-connector-service.ts
Normal file
266
src/bridge/chat/chat-connector-service.ts
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
/**
|
||||||
|
* chat-connector-service: mention → binding lookup → in-process A2A principal
|
||||||
|
* → MaestroA2aExecutor.execute() → reply (issue #801, PR1 — Slack MVP).
|
||||||
|
*
|
||||||
|
* This module is the single place that bridges an external chat mention into
|
||||||
|
* the existing A2A execution path. It deliberately reuses, rather than
|
||||||
|
* reimplements:
|
||||||
|
* - MaestroA2aExecutor.execute() (src/bridge/a2a/executor.ts) for job
|
||||||
|
* creation, polling, and terminal-state handling.
|
||||||
|
* - computeEffectiveScope / isDelegationLive (src/bridge/a2a/delegation.ts)
|
||||||
|
* for the AND-intersected authorization scope and fail-closed revocation
|
||||||
|
* checks — invoked transitively by the executor.
|
||||||
|
* - A2aLimiter (src/bridge/a2a/limiter.ts) for governance. Because this
|
||||||
|
* path bypasses the JSON-RPC AuthGatedRequestHandler (request-handler.ts),
|
||||||
|
* which normally enforces payload-size + rate-limit checks BEFORE calling
|
||||||
|
* execute(), this module replicates that same ingress gate explicitly
|
||||||
|
* (see `enforceIngress` below) using the identical A2aLimiter instance —
|
||||||
|
* so rate/concurrency/skill-budget counters are shared across the A2A
|
||||||
|
* JSON-RPC path and the chat path for the same grant.
|
||||||
|
* - local_task_comments 'result'/'ask' rows (via Repository.getLatestResultComment)
|
||||||
|
* for the reply summary, written by LocalProgressReporter.reportFinalResult /
|
||||||
|
* reportAsk — no new result-reporting path is introduced.
|
||||||
|
*
|
||||||
|
* fail-closed points (all silently ignore rather than error, per the issue's
|
||||||
|
* "existing binding" model — an attacker probing channel/team ids should not
|
||||||
|
* learn whether a binding exists):
|
||||||
|
* - no active binding for (platform, team, channel)
|
||||||
|
* - delegation missing / revoked / expired / no grant_id
|
||||||
|
* - bot credential decryption failure
|
||||||
|
* - payload too large / rate limit exceeded (replies with a short notice —
|
||||||
|
* these are legitimate-binding-holder-visible failures, not enumeration risks)
|
||||||
|
*/
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { Repository, A2aDelegationRow } from '../../db/repository.js';
|
||||||
|
import { isDelegationLive } from '../a2a/delegation.js';
|
||||||
|
import type { A2aPrincipal } from '../a2a/token-auth.js';
|
||||||
|
import { MaestroA2aExecutor } from '../a2a/executor.js';
|
||||||
|
import { A2aLimiter } from '../a2a/limiter.js';
|
||||||
|
import { decrypt, loadKeyFromEnv } from '../../mcp/crypto.js';
|
||||||
|
import { SlackChatClient } from './slack-adapter.js';
|
||||||
|
import type { ChatMentionEvent, SlackBotCredentials } from './chat-connector-types.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
|
||||||
|
const MAX_REPLY_CHARS = 800;
|
||||||
|
|
||||||
|
export interface ChatConnectorServiceDeps {
|
||||||
|
repo: Repository;
|
||||||
|
/** Shared with the A2A JSON-RPC path (see a2a-subsystem.ts) so governance
|
||||||
|
* counters are keyed per-grant regardless of entry point. Omit only in
|
||||||
|
* tests that don't care about governance. */
|
||||||
|
limiter?: A2aLimiter;
|
||||||
|
/** Absolute base URL for task-detail links in replies. Omit to send no link. */
|
||||||
|
publicBaseUrl?: string;
|
||||||
|
now?: () => string;
|
||||||
|
/** Test seam: inject a fake executor instead of a real MaestroA2aExecutor. */
|
||||||
|
executorFactory?: (deps: { repo: Repository; limiter?: A2aLimiter }) => { execute: MaestroA2aExecutor['execute'] };
|
||||||
|
/** Test seam: inject a fake Slack client instead of a real network call. */
|
||||||
|
chatClientFactory?: (credentials: SlackBotCredentials) => Pick<SlackChatClient, 'postMessage'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal ExecutionEventBus — collects published events and resolves once execute() calls finished(). */
|
||||||
|
function makeCollectingEventBus() {
|
||||||
|
const events: Array<Record<string, any>> = [];
|
||||||
|
let resolveFinished!: () => void;
|
||||||
|
const finishedPromise = new Promise<void>((resolve) => { resolveFinished = resolve; });
|
||||||
|
const bus = {
|
||||||
|
publish(event: Record<string, any>) { events.push(event); },
|
||||||
|
finished() { resolveFinished(); },
|
||||||
|
on() { return bus; },
|
||||||
|
off() { return bus; },
|
||||||
|
once() { return bus; },
|
||||||
|
removeAllListeners() { return bus; },
|
||||||
|
};
|
||||||
|
return { bus, events, finishedPromise };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractText(message: any): string | undefined {
|
||||||
|
const part = message?.parts?.find((p: any) => p.kind === 'text');
|
||||||
|
return typeof part?.text === 'string' ? part.text : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExecutorOutcome {
|
||||||
|
state: string;
|
||||||
|
message?: string;
|
||||||
|
localTaskId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOutcome(events: Array<Record<string, any>>): ExecutorOutcome {
|
||||||
|
let localTaskId: number | undefined;
|
||||||
|
let state = 'unknown';
|
||||||
|
let message: string | undefined;
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev.kind === 'task') {
|
||||||
|
if (ev.metadata?.maestroLocalTaskId != null) localTaskId = ev.metadata.maestroLocalTaskId;
|
||||||
|
}
|
||||||
|
if (ev.kind === 'task' || ev.kind === 'status-update') {
|
||||||
|
if (ev.status?.state) {
|
||||||
|
state = ev.status.state;
|
||||||
|
const text = extractText(ev.status.message);
|
||||||
|
if (text) message = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { state, message, localTaskId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text: string, max: number): string {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.length <= max) return trimmed;
|
||||||
|
return trimmed.slice(0, max) + '…(省略)';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildReply(outcome: ExecutorOutcome, repo: Repository, spaceId: string, publicBaseUrl?: string): Promise<string> {
|
||||||
|
const link = (publicBaseUrl && outcome.localTaskId != null)
|
||||||
|
? `\n${publicBaseUrl.replace(/\/$/, '')}/?page=spaces&space=${encodeURIComponent(spaceId)}&chat=${outcome.localTaskId}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (outcome.localTaskId != null) {
|
||||||
|
let resultComment: { body: string; kind: string } | null = null;
|
||||||
|
try {
|
||||||
|
resultComment = await repo.getLatestResultComment(outcome.localTaskId);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[chat-connector] getLatestResultComment failed taskId=${outcome.localTaskId}: ${err}`);
|
||||||
|
}
|
||||||
|
if (resultComment) {
|
||||||
|
return `${truncate(resultComment.body, MAX_REPLY_CHARS)}${link}`;
|
||||||
|
}
|
||||||
|
if (outcome.message) return `${truncate(outcome.message, MAX_REPLY_CHARS)}${link}`;
|
||||||
|
return `タスクを実行しました。${link}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No local task was ever created (denied before task creation, e.g. out-of-scope or governance reject).
|
||||||
|
return outcome.message ? `⚠️ ${truncate(outcome.message, MAX_REPLY_CHARS)}` : '⚠️ リクエストを処理できませんでした。';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrincipal(delegation: A2aDelegationRow): A2aPrincipal {
|
||||||
|
return {
|
||||||
|
actingUserId: delegation.userId,
|
||||||
|
clientId: delegation.clientId,
|
||||||
|
grantId: delegation.grantId as string,
|
||||||
|
delegation: {
|
||||||
|
id: delegation.id,
|
||||||
|
userId: delegation.userId,
|
||||||
|
clientId: delegation.clientId,
|
||||||
|
grantId: delegation.grantId as string,
|
||||||
|
grantedSpaceIds: delegation.grantedSpaceIds,
|
||||||
|
grantedSkills: delegation.grantedSkills,
|
||||||
|
expiresAt: delegation.expiresAt,
|
||||||
|
revokedAt: delegation.revokedAt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles one inbound Slack app_mention: resolves the binding, builds an
|
||||||
|
* in-process A2A principal from the bound delegation, runs the message
|
||||||
|
* through MaestroA2aExecutor, and posts the result back to Slack.
|
||||||
|
*
|
||||||
|
* Never throws — all failure paths are logged and either silently ignored
|
||||||
|
* (fail-closed authorization gaps) or answered with a short notice
|
||||||
|
* (governance gaps visible to a legitimate binding holder).
|
||||||
|
*/
|
||||||
|
export async function handleSlackMention(event: ChatMentionEvent, deps: ChatConnectorServiceDeps): Promise<void> {
|
||||||
|
const { repo, limiter, publicBaseUrl } = deps;
|
||||||
|
const now = deps.now ?? (() => new Date().toISOString());
|
||||||
|
|
||||||
|
const binding = repo.findActiveChatConnectorBinding(event.platform, event.externalWorkspaceId, event.externalChannelId);
|
||||||
|
if (!binding) {
|
||||||
|
logger.info(`[chat-connector] no active binding platform=${event.platform} team=${event.externalWorkspaceId} channel=${event.externalChannelId} — ignoring`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delegation = repo.getA2aDelegationById(binding.a2aDelegationId);
|
||||||
|
const nowIso = now();
|
||||||
|
if (!delegation || !delegation.grantId || !isDelegationLive(delegation, nowIso)) {
|
||||||
|
logger.info(`[chat-connector] delegation not live binding=${binding.id} — ignoring`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.text.trim()) {
|
||||||
|
logger.info(`[chat-connector] empty mention text binding=${binding.id} — ignoring`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let credentials: SlackBotCredentials;
|
||||||
|
try {
|
||||||
|
const plaintext = decrypt(binding.botCredentialsEnc, loadKeyFromEnv());
|
||||||
|
credentials = JSON.parse(plaintext) as SlackBotCredentials;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[chat-connector] failed to decrypt bot credentials binding=${binding.id}: ${err}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = deps.chatClientFactory ? deps.chatClientFactory(credentials) : new SlackChatClient(credentials);
|
||||||
|
const threadTs = event.threadTs ?? event.messageTs;
|
||||||
|
const reply = (text: string) => client.postMessage({ channel: event.externalChannelId, text, threadTs }).catch((err) => {
|
||||||
|
logger.warn(`[chat-connector] reply post failed binding=${binding.id}: ${err}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const principal = buildPrincipal(delegation);
|
||||||
|
|
||||||
|
// Governance: replicate request-handler.ts's enforceIngress (payload size + rate)
|
||||||
|
// using the SAME A2aLimiter instance passed in from a2a-subsystem.ts, so this
|
||||||
|
// path shares budget/counters with the JSON-RPC A2A path for the same grant.
|
||||||
|
// Concurrency + skill-budget are enforced inside MaestroA2aExecutor.execute()
|
||||||
|
// itself (unchanged) — only rate + payload need to be replicated here because
|
||||||
|
// they normally live in the JSON-RPC ingress layer this path bypasses.
|
||||||
|
if (limiter) {
|
||||||
|
const bytes = Buffer.byteLength(event.text, 'utf8');
|
||||||
|
if (bytes > limiter.limits.maxPayloadBytes) {
|
||||||
|
logger.info(`[chat-connector] payload too large binding=${binding.id} bytes=${bytes}`);
|
||||||
|
await reply('⚠️ メッセージが長すぎます。短くしてもう一度お試しください。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!limiter.tryConsumeRate(principal.grantId)) {
|
||||||
|
logger.info(`[chat-connector] rate limit exceeded binding=${binding.id} grant=${principal.grantId}`);
|
||||||
|
await reply('⚠️ リクエストが多すぎます。しばらく待ってから再度お試しください。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const executor = deps.executorFactory
|
||||||
|
? deps.executorFactory({ repo, limiter })
|
||||||
|
: new MaestroA2aExecutor(repo, { limiter, pollMs: 1000 });
|
||||||
|
|
||||||
|
const taskId = randomUUID();
|
||||||
|
const contextId = randomUUID();
|
||||||
|
const requestContext = {
|
||||||
|
taskId,
|
||||||
|
contextId,
|
||||||
|
userMessage: {
|
||||||
|
kind: 'message',
|
||||||
|
messageId: randomUUID(),
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ kind: 'text', text: event.text }],
|
||||||
|
contextId,
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
context: { user: { a2aPrincipal: principal, isAuthenticated: true, userName: principal.actingUserId } },
|
||||||
|
} as unknown as Parameters<MaestroA2aExecutor['execute']>[0];
|
||||||
|
|
||||||
|
const { bus, events, finishedPromise } = makeCollectingEventBus();
|
||||||
|
try {
|
||||||
|
await executor.execute(requestContext, bus as any);
|
||||||
|
await finishedPromise;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[chat-connector] executor error binding=${binding.id}: ${err}`);
|
||||||
|
await reply('⚠️ 内部エラーによりタスクを実行できませんでした。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = extractOutcome(events);
|
||||||
|
try {
|
||||||
|
await repo.addAuditLog(null, 'chat.mention.handled', 'chat', {
|
||||||
|
bindingId: binding.id,
|
||||||
|
platform: event.platform,
|
||||||
|
spaceId: binding.spaceId,
|
||||||
|
grantId: principal.grantId,
|
||||||
|
state: outcome.state,
|
||||||
|
localTaskId: outcome.localTaskId ?? null,
|
||||||
|
});
|
||||||
|
} catch { /* best-effort audit */ }
|
||||||
|
|
||||||
|
const replyText = await buildReply(outcome, repo, binding.spaceId, publicBaseUrl);
|
||||||
|
await reply(replyText);
|
||||||
|
}
|
||||||
34
src/bridge/chat/chat-connector-types.ts
Normal file
34
src/bridge/chat/chat-connector-types.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* chat-connector-types: shared types for the external chat connector
|
||||||
|
* (issue #801, PR1 — Slack MVP). Platform-agnostic so PR2 (Discord) / PR3
|
||||||
|
* (Teams) can reuse chat-connector-service.ts without changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Bot credentials, decrypted from chat_connector_bindings.bot_credentials_enc.
|
||||||
|
* Never logged, never included in an API response. */
|
||||||
|
export interface SlackBotCredentials {
|
||||||
|
signingSecret: string;
|
||||||
|
botToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalized inbound mention event, platform-agnostic. */
|
||||||
|
export interface ChatMentionEvent {
|
||||||
|
platform: 'slack';
|
||||||
|
externalWorkspaceId: string;
|
||||||
|
externalChannelId: string;
|
||||||
|
/** Mention text with the bot's own `<@BOTID>` token stripped. */
|
||||||
|
text: string;
|
||||||
|
/** Thread to reply into, if the mention occurred inside a thread. */
|
||||||
|
threadTs?: string;
|
||||||
|
/** Platform-native message timestamp, used to reply in-channel when not threaded. */
|
||||||
|
messageTs?: string;
|
||||||
|
/** External user id who sent the mention (best-effort, for audit logging only). */
|
||||||
|
externalUserId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of running a mention through the A2A executor bridge. */
|
||||||
|
export interface ChatRunResult {
|
||||||
|
ok: boolean;
|
||||||
|
/** Human-readable summary to post back to the chat, already truncated. */
|
||||||
|
replyText: string;
|
||||||
|
}
|
||||||
220
src/bridge/chat/chat-subsystem.test.ts
Normal file
220
src/bridge/chat/chat-subsystem.test.ts
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
/**
|
||||||
|
* chat-subsystem HTTP-layer tests: config gating, url_verification handshake,
|
||||||
|
* and the fail-closed dispatch gate (no binding / bad signature both ack 200
|
||||||
|
* without calling handleSlackMention — see slack-adapter.test.ts and
|
||||||
|
* chat-connector-service.test.ts for the unit-level signature/service tests
|
||||||
|
* this route delegates to).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { randomBytes, randomUUID, createHmac } from 'crypto';
|
||||||
|
import { mkdtempSync, rmSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { Repository } from '../../db/repository.js';
|
||||||
|
import { encrypt } from '../../mcp/crypto.js';
|
||||||
|
|
||||||
|
let mockChatConfig: any = { enabled: false };
|
||||||
|
vi.mock('../../config.js', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('../../config.js')>('../../config.js');
|
||||||
|
return { ...actual, loadConfig: vi.fn(() => ({ chat: mockChatConfig })) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSlackMentionMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.mock('./chat-connector-service.js', () => ({
|
||||||
|
handleSlackMention: (...args: unknown[]) => handleSlackMentionMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../auth.js', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('../auth.js')>('../auth.js');
|
||||||
|
return { ...actual, requireAdmin: (_req: any, _res: any, next: any) => next() };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { setupChatSubsystem } from './chat-subsystem.js';
|
||||||
|
|
||||||
|
function sign(secret: string, timestamp: string, body: string): string {
|
||||||
|
return 'v0=' + createHmac('sha256', secret).update(`v0:${timestamp}:${body}`, 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('chat-subsystem: Slack Events webhook', () => {
|
||||||
|
let tempDir = '';
|
||||||
|
let repo: Repository;
|
||||||
|
let ownerId = '';
|
||||||
|
let spaceId = '';
|
||||||
|
let clientId = '';
|
||||||
|
let delegationId = '';
|
||||||
|
let grantId = '';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
handleSlackMentionMock.mockClear();
|
||||||
|
mockChatConfig = { enabled: true, slack: {} };
|
||||||
|
process.env.MCP_ENCRYPTION_KEY = randomBytes(32).toString('hex');
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), 'maestro-chat-subsys-'));
|
||||||
|
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||||
|
const owner = repo.createUser({ email: 'owner@example.com', name: 'Owner', role: 'user', status: 'active' });
|
||||||
|
ownerId = owner.id;
|
||||||
|
const space = await repo.createSpace({ kind: 'case', title: 'Case', ownerId, visibility: 'private' });
|
||||||
|
spaceId = space.id;
|
||||||
|
clientId = `a2a_${randomUUID()}`;
|
||||||
|
repo.createA2aClient({
|
||||||
|
clientId, name: 'Test App', redirectUris: ['https://example.com/cb'],
|
||||||
|
secretHash: null, tokenEndpointAuthMethod: 'none', agentCardUrl: null, issuer: null,
|
||||||
|
keyFingerprint: null, status: 'active', createdBy: null,
|
||||||
|
});
|
||||||
|
delegationId = randomUUID();
|
||||||
|
grantId = randomUUID();
|
||||||
|
repo.createA2aDelegation({
|
||||||
|
id: delegationId, userId: ownerId, clientId, grantId,
|
||||||
|
grantedSpaceIds: [spaceId], grantedSkills: ['chat'],
|
||||||
|
audience: null, expiresAt: null, revokedAt: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
repo.close();
|
||||||
|
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function seedBinding(signingSecret = 'correct-secret') {
|
||||||
|
const key = Buffer.from(process.env.MCP_ENCRYPTION_KEY!, 'hex');
|
||||||
|
return repo.createChatConnectorBinding({
|
||||||
|
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1',
|
||||||
|
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||||
|
botCredentialsEnc: encrypt(JSON.stringify({ signingSecret, botToken: 'xoxb-test' }), key),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appWith(a2aLimiter: any = {}): express.Express {
|
||||||
|
const app = express();
|
||||||
|
setupChatSubsystem(app, { repo, authActive: true, a2aLimiter });
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not mount the webhook route when chat.enabled is false', async () => {
|
||||||
|
mockChatConfig = { enabled: false };
|
||||||
|
const app = appWith();
|
||||||
|
const res = await request(app).post('/api/chat/slack/events')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send(JSON.stringify({ type: 'url_verification', challenge: 'abc' }));
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mount the webhook when A2A governance is unavailable', async () => {
|
||||||
|
const app = appWith(null);
|
||||||
|
const res = await request(app).post('/api/chat/slack/events').send('{}');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('echoes the url_verification challenge with no binding lookup and no dispatch', async () => {
|
||||||
|
const app = appWith();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/chat/slack/events')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send(JSON.stringify({ type: 'url_verification', challenge: 'chal-123' }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.challenge).toBe('chal-123');
|
||||||
|
expect(handleSlackMentionMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('acks 200 without dispatching when no binding matches the (team, channel) — fail-closed, no enumeration signal', async () => {
|
||||||
|
const app = appWith();
|
||||||
|
const body = JSON.stringify({
|
||||||
|
type: 'event_callback', team_id: 'T-unknown',
|
||||||
|
event: { type: 'app_mention', channel: 'C-unknown', text: 'hi', ts: '1.1' },
|
||||||
|
});
|
||||||
|
const res = await request(app).post('/api/chat/slack/events').set('Content-Type', 'application/json').send(body);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(handleSlackMentionMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('acks 200 without dispatching on an invalid signature — same status as the no-binding case', async () => {
|
||||||
|
seedBinding();
|
||||||
|
const app = appWith();
|
||||||
|
const body = JSON.stringify({
|
||||||
|
type: 'event_callback', event_id: 'Ev-valid-1', team_id: 'T1',
|
||||||
|
event: { type: 'app_mention', channel: 'C1', text: 'hi', ts: '1.1' },
|
||||||
|
});
|
||||||
|
const res = await request(app).post('/api/chat/slack/events')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.set('X-Slack-Signature', 'v0=' + '0'.repeat(64))
|
||||||
|
.set('X-Slack-Request-Timestamp', String(Math.floor(Date.now() / 1000)))
|
||||||
|
.send(body);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(handleSlackMentionMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('acks 200 without dispatching on a replayed (stale) timestamp', async () => {
|
||||||
|
seedBinding();
|
||||||
|
const app = appWith();
|
||||||
|
const body = JSON.stringify({
|
||||||
|
type: 'event_callback', event_id: 'Ev-valid-dispatch-1', team_id: 'T1',
|
||||||
|
event: { type: 'app_mention', channel: 'C1', text: 'hi', ts: '1.1' },
|
||||||
|
});
|
||||||
|
const staleTimestamp = String(Math.floor(Date.now() / 1000) - 3600); // 1h old
|
||||||
|
const res = await request(app).post('/api/chat/slack/events')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.set('X-Slack-Signature', sign('correct-secret', staleTimestamp, body))
|
||||||
|
.set('X-Slack-Request-Timestamp', staleTimestamp)
|
||||||
|
.send(body);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(handleSlackMentionMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches handleSlackMention exactly once on a validly signed app_mention', async () => {
|
||||||
|
seedBinding();
|
||||||
|
const app = appWith();
|
||||||
|
const body = JSON.stringify({
|
||||||
|
type: 'event_callback', event_id: 'Ev-valid-mention-1', team_id: 'T1',
|
||||||
|
event: { type: 'app_mention', channel: 'C1', text: '<@U1> hi there', ts: '1700000000.0001' },
|
||||||
|
});
|
||||||
|
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||||
|
const res = await request(app).post('/api/chat/slack/events')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.set('X-Slack-Signature', sign('correct-secret', timestamp, body))
|
||||||
|
.set('X-Slack-Request-Timestamp', timestamp)
|
||||||
|
.send(body);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(handleSlackMentionMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [event] = handleSlackMentionMock.mock.calls[0];
|
||||||
|
expect(event.externalWorkspaceId).toBe('T1');
|
||||||
|
expect(event.externalChannelId).toBe('C1');
|
||||||
|
expect(event.text).toBe('hi there');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('acknowledges a Slack retry without dispatching the same event twice', async () => {
|
||||||
|
seedBinding();
|
||||||
|
const app = appWith();
|
||||||
|
const body = JSON.stringify({
|
||||||
|
type: 'event_callback', event_id: 'Ev-retry-1', team_id: 'T1',
|
||||||
|
event: { type: 'app_mention', channel: 'C1', text: '<@U1> once', ts: '1700000000.0001' },
|
||||||
|
});
|
||||||
|
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||||
|
const headers = { 'Content-Type': 'application/json', 'X-Slack-Signature': sign('correct-secret', timestamp, body), 'X-Slack-Request-Timestamp': timestamp };
|
||||||
|
expect((await request(app).post('/api/chat/slack/events').set(headers).send(body)).status).toBe(200);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect((await request(app).post('/api/chat/slack/events').set(headers).send(body)).status).toBe(200);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(handleSlackMentionMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not dispatch for a non-app_mention event_callback (e.g. plain message)', async () => {
|
||||||
|
seedBinding();
|
||||||
|
const app = appWith();
|
||||||
|
const body = JSON.stringify({
|
||||||
|
type: 'event_callback', team_id: 'T1',
|
||||||
|
event: { type: 'message', channel: 'C1', text: 'not a mention', ts: '1.1' },
|
||||||
|
});
|
||||||
|
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||||
|
const res = await request(app).post('/api/chat/slack/events')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.set('X-Slack-Signature', sign('correct-secret', timestamp, body))
|
||||||
|
.set('X-Slack-Request-Timestamp', timestamp)
|
||||||
|
.send(body);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(handleSlackMentionMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
139
src/bridge/chat/chat-subsystem.ts
Normal file
139
src/bridge/chat/chat-subsystem.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* chat-subsystem: mounts the external chat connector (issue #801, PR1 —
|
||||||
|
* Slack MVP). No-op unless `chat.enabled: true` in config.yaml. Follows the
|
||||||
|
* same "verbatim extraction, gated by config" shape as a2a-subsystem.ts.
|
||||||
|
*/
|
||||||
|
import express from 'express';
|
||||||
|
import type { Repository } from '../../db/repository.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
import { loadConfig } from '../../config.js';
|
||||||
|
import { requireAdmin } from '../auth.js';
|
||||||
|
import { createChatConnectorBindingsAdminRouter } from './chat-connector-bindings-api.js';
|
||||||
|
import {
|
||||||
|
verifySlackSignature,
|
||||||
|
isUrlVerification,
|
||||||
|
parseAppMentionEvent,
|
||||||
|
DEFAULT_SLACK_SIGNING_MAX_AGE_SEC,
|
||||||
|
} from './slack-adapter.js';
|
||||||
|
import { handleSlackMention } from './chat-connector-service.js';
|
||||||
|
import type { A2aLimiter } from '../a2a/limiter.js';
|
||||||
|
import { decrypt, loadKeyFromEnv } from '../../mcp/crypto.js';
|
||||||
|
|
||||||
|
export interface ChatSubsystemDeps {
|
||||||
|
repo: Repository;
|
||||||
|
authActive: boolean;
|
||||||
|
/** Shared A2A governance limiter (see a2a-subsystem.ts). Undefined when a2a
|
||||||
|
* is not enabled — in that case the chat path still runs but with no
|
||||||
|
* rate/concurrency/skill-budget governance beyond what the executor itself
|
||||||
|
* enforces without a limiter (none), so operators should enable a2a too. */
|
||||||
|
a2aLimiter?: A2aLimiter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupChatSubsystem(app: express.Express, deps: ChatSubsystemDeps): void {
|
||||||
|
const { repo, authActive, a2aLimiter } = deps;
|
||||||
|
const chatCfg = (loadConfig() as any).chat ?? {};
|
||||||
|
if (!chatCfg.enabled) return;
|
||||||
|
|
||||||
|
if (!authActive) {
|
||||||
|
logger.warn('[chat-connector] chat.enabled but auth is not active; admin binding API will reject all requests');
|
||||||
|
}
|
||||||
|
// --- Admin CRUD for bindings (no UI wizard in PR1 — manual seed only) ---
|
||||||
|
app.use('/api/admin/chat/bindings', express.json(), requireAdmin, createChatConnectorBindingsAdminRouter(repo, authActive));
|
||||||
|
|
||||||
|
// The mention path must share A2A's limiter. Do not mount it when A2A did
|
||||||
|
// not initialize: otherwise an external webhook bypasses rate/concurrency
|
||||||
|
// and skill-budget governance despite the admin configuration saying it is
|
||||||
|
// disabled.
|
||||||
|
if (!a2aLimiter) {
|
||||||
|
logger.warn('[chat-connector] chat.enabled but a2a.enabled is false (or a2a failed to mount); Slack webhook is disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAgeSec: number = chatCfg.slack?.signingSecretMaxAgeSec ?? DEFAULT_SLACK_SIGNING_MAX_AGE_SEC;
|
||||||
|
const publicBaseUrl: string | undefined = chatCfg.publicBaseUrl;
|
||||||
|
|
||||||
|
// --- Slack Events API webhook ---
|
||||||
|
// express.raw() (not json()) — signature verification requires the exact
|
||||||
|
// raw bytes Slack signed. Must ack within Slack's ~3s budget: verification
|
||||||
|
// is synchronous/local (DB lookup + HMAC, no outbound network), so we
|
||||||
|
// finish it before responding, then run the actual task fire-and-forget.
|
||||||
|
app.post('/api/chat/slack/events', express.raw({ type: 'application/json', limit: '1mb' }), (req, res) => {
|
||||||
|
const rawBody = req.body as Buffer;
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(rawBody.toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
res.status(400).json({ error: 'invalid JSON' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// url_verification handshake: Slack sends this once, when the Events API
|
||||||
|
// Request URL is first configured, before any binding necessarily exists
|
||||||
|
// for that team. There is no channel to key a per-binding signing secret
|
||||||
|
// lookup on, so PR1 does not verify this specific call — it has no side
|
||||||
|
// effects (it only echoes back the challenge value Slack itself sent).
|
||||||
|
if (isUrlVerification(parsed)) {
|
||||||
|
res.status(200).json({ challenge: parsed.challenge });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamId = (parsed as any)?.team_id;
|
||||||
|
const channelId = (parsed as any)?.event?.channel;
|
||||||
|
if (typeof teamId !== 'string' || typeof channelId !== 'string') {
|
||||||
|
res.status(200).json({ ok: true }); // nothing routable — ack and drop
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const binding = repo.findActiveChatConnectorBinding('slack', teamId, channelId);
|
||||||
|
if (!binding) {
|
||||||
|
// fail-closed, no enumeration signal: identical 200 ack whether or not
|
||||||
|
// a binding exists for this team/channel.
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let signingSecret: string;
|
||||||
|
try {
|
||||||
|
const decrypted = decrypt(binding.botCredentialsEnc, loadKeyFromEnv());
|
||||||
|
signingSecret = (JSON.parse(decrypted) as { signingSecret: string }).signingSecret;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[chat-connector] failed to decrypt signing secret binding=${binding.id}: ${err}`);
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const verified = verifySlackSignature(
|
||||||
|
{ signature: req.header('X-Slack-Signature'), timestamp: req.header('X-Slack-Request-Timestamp') },
|
||||||
|
rawBody,
|
||||||
|
signingSecret,
|
||||||
|
Math.floor(Date.now() / 1000),
|
||||||
|
maxAgeSec,
|
||||||
|
);
|
||||||
|
if (!verified) {
|
||||||
|
// Same 200 ack as "no binding" — do not leak signature-validity via status code.
|
||||||
|
logger.warn(`[chat-connector] signature verification failed binding=${binding.id}`);
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventId = (parsed as any)?.event_id;
|
||||||
|
if (typeof eventId !== 'string' || !repo.claimChatConnectorEvent(eventId)) {
|
||||||
|
// Slack retries use the same envelope event_id. Acknowledge the retry
|
||||||
|
// without creating a second task or posting a duplicate thread reply.
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verified — ack immediately, then run the task in the background.
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
|
||||||
|
const event = parseAppMentionEvent(parsed);
|
||||||
|
if (!event) return; // not an app_mention (or bot/edited-message echo) — nothing to do
|
||||||
|
|
||||||
|
handleSlackMention(event, { repo, limiter: a2aLimiter, publicBaseUrl }).catch((err) => {
|
||||||
|
logger.warn(`[chat-connector] handleSlackMention failed: ${err}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('[chat-connector] chat connector subsystem enabled (slack)');
|
||||||
|
}
|
||||||
193
src/bridge/chat/slack-adapter.test.ts
Normal file
193
src/bridge/chat/slack-adapter.test.ts
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
verifySlackSignature,
|
||||||
|
isUrlVerification,
|
||||||
|
parseAppMentionEvent,
|
||||||
|
SlackChatClient,
|
||||||
|
DEFAULT_SLACK_SIGNING_MAX_AGE_SEC,
|
||||||
|
} from './slack-adapter.js';
|
||||||
|
|
||||||
|
const SECRET = 'test-signing-secret';
|
||||||
|
|
||||||
|
function sign(timestamp: string, body: string, secret: string = SECRET): string {
|
||||||
|
const base = `v0:${timestamp}:${body}`;
|
||||||
|
return 'v0=' + createHmac('sha256', secret).update(base, 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('verifySlackSignature', () => {
|
||||||
|
it('accepts a correctly signed, fresh request', () => {
|
||||||
|
const nowSec = 1_700_000_000;
|
||||||
|
const body = JSON.stringify({ hello: 'world' });
|
||||||
|
const timestamp = String(nowSec - 5);
|
||||||
|
const signature = sign(timestamp, body);
|
||||||
|
expect(verifySlackSignature({ signature, timestamp }, body, SECRET, nowSec)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a tampered body (signature mismatch)', () => {
|
||||||
|
const nowSec = 1_700_000_000;
|
||||||
|
const timestamp = String(nowSec - 5);
|
||||||
|
const signature = sign(timestamp, JSON.stringify({ hello: 'world' }));
|
||||||
|
const tamperedBody = JSON.stringify({ hello: 'mallory' });
|
||||||
|
expect(verifySlackSignature({ signature, timestamp }, tamperedBody, SECRET, nowSec)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a signature produced with the wrong secret', () => {
|
||||||
|
const nowSec = 1_700_000_000;
|
||||||
|
const body = JSON.stringify({ hello: 'world' });
|
||||||
|
const timestamp = String(nowSec - 5);
|
||||||
|
const signature = sign(timestamp, body, 'wrong-secret');
|
||||||
|
expect(verifySlackSignature({ signature, timestamp }, body, SECRET, nowSec)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a replayed (stale) timestamp beyond maxAgeSec', () => {
|
||||||
|
const nowSec = 1_700_000_000;
|
||||||
|
const body = JSON.stringify({ hello: 'world' });
|
||||||
|
const staleTimestamp = String(nowSec - (DEFAULT_SLACK_SIGNING_MAX_AGE_SEC + 60));
|
||||||
|
const signature = sign(staleTimestamp, body);
|
||||||
|
expect(verifySlackSignature({ signature, timestamp: staleTimestamp }, body, SECRET, nowSec)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a timestamp far in the future (clock-skew abuse)', () => {
|
||||||
|
const nowSec = 1_700_000_000;
|
||||||
|
const body = JSON.stringify({ hello: 'world' });
|
||||||
|
const futureTimestamp = String(nowSec + (DEFAULT_SLACK_SIGNING_MAX_AGE_SEC + 60));
|
||||||
|
const signature = sign(futureTimestamp, body);
|
||||||
|
expect(verifySlackSignature({ signature, timestamp: futureTimestamp }, body, SECRET, nowSec)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fail-closed: rejects when the signature header is missing', () => {
|
||||||
|
const nowSec = 1_700_000_000;
|
||||||
|
expect(verifySlackSignature({ signature: undefined, timestamp: String(nowSec) }, '{}', SECRET, nowSec)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fail-closed: rejects when the timestamp header is missing', () => {
|
||||||
|
const nowSec = 1_700_000_000;
|
||||||
|
expect(verifySlackSignature({ signature: 'v0=deadbeef', timestamp: undefined }, '{}', SECRET, nowSec)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fail-closed: rejects a non-numeric timestamp', () => {
|
||||||
|
const nowSec = 1_700_000_000;
|
||||||
|
expect(verifySlackSignature({ signature: 'v0=deadbeef', timestamp: 'not-a-number' }, '{}', SECRET, nowSec)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a Buffer body identically to the equivalent string', () => {
|
||||||
|
const nowSec = 1_700_000_000;
|
||||||
|
const body = JSON.stringify({ hello: 'world' });
|
||||||
|
const timestamp = String(nowSec - 5);
|
||||||
|
const signature = sign(timestamp, body);
|
||||||
|
expect(verifySlackSignature({ signature, timestamp }, Buffer.from(body, 'utf8'), SECRET, nowSec)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isUrlVerification', () => {
|
||||||
|
it('recognizes a well-formed url_verification payload', () => {
|
||||||
|
expect(isUrlVerification({ type: 'url_verification', challenge: 'abc123' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects payloads missing the challenge field', () => {
|
||||||
|
expect(isUrlVerification({ type: 'url_verification' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects event_callback payloads', () => {
|
||||||
|
expect(isUrlVerification({ type: 'event_callback', event: {} })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects null/non-object input', () => {
|
||||||
|
expect(isUrlVerification(null)).toBe(false);
|
||||||
|
expect(isUrlVerification('challenge')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseAppMentionEvent', () => {
|
||||||
|
const envelope = (event: Record<string, unknown>, teamId = 'T123') => ({
|
||||||
|
type: 'event_callback',
|
||||||
|
team_id: teamId,
|
||||||
|
event: { type: 'app_mention', channel: 'C456', text: '<@U000BOT> summarize the thread', ts: '1700000000.000100', ...event },
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a genuine app_mention and strips the bot mention token', () => {
|
||||||
|
const result = parseAppMentionEvent(envelope({}), 'U000BOT');
|
||||||
|
expect(result).toEqual({
|
||||||
|
platform: 'slack',
|
||||||
|
externalWorkspaceId: 'T123',
|
||||||
|
externalChannelId: 'C456',
|
||||||
|
text: 'summarize the thread',
|
||||||
|
threadTs: undefined,
|
||||||
|
messageTs: '1700000000.000100',
|
||||||
|
externalUserId: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries thread_ts and user when present', () => {
|
||||||
|
const result = parseAppMentionEvent(envelope({ thread_ts: '1700000000.000050', user: 'U999' }), 'U000BOT');
|
||||||
|
expect(result?.threadTs).toBe('1700000000.000050');
|
||||||
|
expect(result?.externalUserId).toBe('U999');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('best-effort strips a leading mention token when botUserId is unknown', () => {
|
||||||
|
const result = parseAppMentionEvent(envelope({}));
|
||||||
|
expect(result?.text).toBe('summarize the thread');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-app_mention event types', () => {
|
||||||
|
expect(parseAppMentionEvent(envelope({ type: 'message' }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores bot-authored messages (bot_id present)', () => {
|
||||||
|
expect(parseAppMentionEvent(envelope({ bot_id: 'B123' }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores edited/deleted echoes (subtype present)', () => {
|
||||||
|
expect(parseAppMentionEvent(envelope({ subtype: 'message_changed' }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-event_callback envelopes', () => {
|
||||||
|
expect(parseAppMentionEvent({ type: 'url_verification', challenge: 'x' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores envelopes missing team_id/channel/text', () => {
|
||||||
|
expect(parseAppMentionEvent({ type: 'event_callback', event: { type: 'app_mention' } })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-object input', () => {
|
||||||
|
expect(parseAppMentionEvent(null)).toBeNull();
|
||||||
|
expect(parseAppMentionEvent('nope')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SlackChatClient.postMessage', () => {
|
||||||
|
it('posts to chat.postMessage with the bot token and returns true on ok', async () => {
|
||||||
|
const fetchImpl = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ ok: true }),
|
||||||
|
});
|
||||||
|
const client = new SlackChatClient({ signingSecret: SECRET, botToken: 'test-slack-bot-token' }, fetchImpl as unknown as typeof fetch);
|
||||||
|
const result = await client.postMessage({ channel: 'C456', text: 'hello', threadTs: '1700000000.000100' });
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, init] = fetchImpl.mock.calls[0];
|
||||||
|
expect(url).toBe('https://slack.com/api/chat.postMessage');
|
||||||
|
expect(init.headers.Authorization).toBe('Bearer test-slack-bot-token');
|
||||||
|
const body = JSON.parse(init.body);
|
||||||
|
expect(body).toEqual({ channel: 'C456', text: 'hello', thread_ts: '1700000000.000100' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false and does not throw on a Slack API error response', async () => {
|
||||||
|
const fetchImpl = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ ok: false, error: 'channel_not_found' }),
|
||||||
|
});
|
||||||
|
const client = new SlackChatClient({ signingSecret: SECRET, botToken: 'test-slack-bot-token' }, fetchImpl as unknown as typeof fetch);
|
||||||
|
const result = await client.postMessage({ channel: 'C456', text: 'hello' });
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false and does not throw on a network error', async () => {
|
||||||
|
const fetchImpl = vi.fn().mockRejectedValue(new Error('ECONNRESET'));
|
||||||
|
const client = new SlackChatClient({ signingSecret: SECRET, botToken: 'test-slack-bot-token' }, fetchImpl as unknown as typeof fetch);
|
||||||
|
const result = await client.postMessage({ channel: 'C456', text: 'hello' });
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
155
src/bridge/chat/slack-adapter.ts
Normal file
155
src/bridge/chat/slack-adapter.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* slack-adapter: Slack Events API signature verification, app_mention
|
||||||
|
* parsing, and the `chat.postMessage` reply client (issue #801, PR1).
|
||||||
|
*
|
||||||
|
* Signature verification follows Slack's documented scheme:
|
||||||
|
* https://api.slack.com/authentication/verifying-requests-from-slack
|
||||||
|
* base = "v0:" + timestamp + ":" + rawBody
|
||||||
|
* expected = "v0=" + HMAC_SHA256(signingSecret, base)
|
||||||
|
* fail-closed: any missing header, malformed signature, or a timestamp older
|
||||||
|
* than `maxAgeSec` (replay protection) is rejected.
|
||||||
|
*/
|
||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
import type { SlackBotCredentials, ChatMentionEvent } from './chat-connector-types.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
|
||||||
|
export const DEFAULT_SLACK_SIGNING_MAX_AGE_SEC = 300; // 5 minutes, matches Slack's own recommendation
|
||||||
|
|
||||||
|
export interface SlackSignatureHeaders {
|
||||||
|
/** X-Slack-Signature header value, e.g. "v0=abcdef...". */
|
||||||
|
signature: string | undefined;
|
||||||
|
/** X-Slack-Request-Timestamp header value (unix seconds as string). */
|
||||||
|
timestamp: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a Slack Events API request signature against the raw request body.
|
||||||
|
* fail-closed: returns false on any missing/malformed input, signature
|
||||||
|
* mismatch, or stale timestamp (replay protection).
|
||||||
|
*/
|
||||||
|
export function verifySlackSignature(
|
||||||
|
headers: SlackSignatureHeaders,
|
||||||
|
rawBody: Buffer | string,
|
||||||
|
signingSecret: string,
|
||||||
|
nowSec: number = Math.floor(Date.now() / 1000),
|
||||||
|
maxAgeSec: number = DEFAULT_SLACK_SIGNING_MAX_AGE_SEC,
|
||||||
|
): boolean {
|
||||||
|
const { signature, timestamp } = headers;
|
||||||
|
if (!signature || !timestamp) return false;
|
||||||
|
if (!/^\d+$/.test(timestamp)) return false;
|
||||||
|
|
||||||
|
const tsNum = Number(timestamp);
|
||||||
|
if (!Number.isFinite(tsNum)) return false;
|
||||||
|
// Replay protection: reject requests whose timestamp is too old OR
|
||||||
|
// suspiciously in the future (clock skew abuse). fail-closed both ways.
|
||||||
|
if (Math.abs(nowSec - tsNum) > maxAgeSec) return false;
|
||||||
|
|
||||||
|
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8');
|
||||||
|
const base = `v0:${timestamp}:${body}`;
|
||||||
|
const expected = 'v0=' + createHmac('sha256', signingSecret).update(base, 'utf8').digest('hex');
|
||||||
|
|
||||||
|
const expectedBuf = Buffer.from(expected, 'utf8');
|
||||||
|
const actualBuf = Buffer.from(signature, 'utf8');
|
||||||
|
if (expectedBuf.length !== actualBuf.length) return false;
|
||||||
|
try {
|
||||||
|
return timingSafeEqual(expectedBuf, actualBuf);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slack Events API `url_verification` handshake payload. */
|
||||||
|
export interface SlackUrlVerificationPayload {
|
||||||
|
type: 'url_verification';
|
||||||
|
challenge: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUrlVerification(body: unknown): body is SlackUrlVerificationPayload {
|
||||||
|
return !!body && typeof body === 'object' && (body as any).type === 'url_verification'
|
||||||
|
&& typeof (body as any).challenge === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlackEventEnvelope {
|
||||||
|
type?: string;
|
||||||
|
team_id?: string;
|
||||||
|
event?: {
|
||||||
|
type?: string;
|
||||||
|
channel?: string;
|
||||||
|
text?: string;
|
||||||
|
thread_ts?: string;
|
||||||
|
ts?: string;
|
||||||
|
user?: string;
|
||||||
|
bot_id?: string;
|
||||||
|
subtype?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a Slack Events API `event_callback` envelope into a normalized
|
||||||
|
* ChatMentionEvent. Returns null when the event is not an app_mention, is
|
||||||
|
* missing required fields, or originates from a bot (including our own bot
|
||||||
|
* — Slack echoes app_mention only for genuine @mentions, but subtype/bot_id
|
||||||
|
* guards against edited/bot-authored messages that could otherwise loop).
|
||||||
|
*/
|
||||||
|
export function parseAppMentionEvent(body: unknown, botUserId?: string): ChatMentionEvent | null {
|
||||||
|
if (!body || typeof body !== 'object') return null;
|
||||||
|
const envelope = body as SlackEventEnvelope;
|
||||||
|
if (envelope.type !== 'event_callback') return null;
|
||||||
|
const event = envelope.event;
|
||||||
|
if (!event || event.type !== 'app_mention') return null;
|
||||||
|
if (event.bot_id) return null; // never act on bot-authored messages
|
||||||
|
if (event.subtype) return null; // ignore message_changed/deleted echoes etc.
|
||||||
|
if (!envelope.team_id || !event.channel || typeof event.text !== 'string') return null;
|
||||||
|
|
||||||
|
// Strip the bot's own mention token (e.g. "<@U012ABC> do the thing" → "do the thing").
|
||||||
|
let text = event.text;
|
||||||
|
if (botUserId) {
|
||||||
|
text = text.replace(new RegExp(`<@${botUserId}>`, 'g'), '').trim();
|
||||||
|
} else {
|
||||||
|
// Best-effort: strip the first leading mention token even if we don't know our own id.
|
||||||
|
text = text.replace(/^\s*<@[A-Z0-9]+>\s*/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform: 'slack',
|
||||||
|
externalWorkspaceId: envelope.team_id,
|
||||||
|
externalChannelId: event.channel,
|
||||||
|
text,
|
||||||
|
threadTs: event.thread_ts,
|
||||||
|
messageTs: event.ts,
|
||||||
|
externalUserId: event.user,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLACK_API_BASE = 'https://slack.com/api';
|
||||||
|
|
||||||
|
/** Thin `chat.postMessage` client. Never logs the bot token. */
|
||||||
|
export class SlackChatClient {
|
||||||
|
constructor(private readonly credentials: SlackBotCredentials, private readonly fetchImpl: typeof fetch = fetch) {}
|
||||||
|
|
||||||
|
async postMessage(opts: { channel: string; text: string; threadTs?: string }): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await this.fetchImpl(`${SLACK_API_BASE}/chat.postMessage`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
Authorization: `Bearer ${this.credentials.botToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
channel: opts.channel,
|
||||||
|
text: opts.text,
|
||||||
|
...(opts.threadTs ? { thread_ts: opts.threadTs } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const json = (await res.json().catch(() => null)) as { ok?: boolean; error?: string } | null;
|
||||||
|
if (!res.ok || !json?.ok) {
|
||||||
|
logger.warn(`[slack-adapter] chat.postMessage failed status=${res.status} error=${json?.error ?? 'unknown'}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[slack-adapter] chat.postMessage error: ${err}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
205
src/bridge/llm-workers-api.test.ts
Normal file
205
src/bridge/llm-workers-api.test.ts
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/llm/workers + validateLlmSelection tests.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* - only workers with an execution role (auto|fast|quality) are returned
|
||||||
|
* - config order preserved
|
||||||
|
* - response never leaks endpoint / apiKey / extraBody
|
||||||
|
* - response shape is exactly {id, model, roles, reasoningEfforts, vlm, enabled}
|
||||||
|
* - validateLlmSelection: one case per rule
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { mkdtempSync, writeFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { ConfigManager } from '../config-manager.js';
|
||||||
|
import type { WorkerDef } from '../config.js';
|
||||||
|
import { createLlmWorkersRouter, validateLlmSelection } from './llm-workers-api.js';
|
||||||
|
|
||||||
|
describe('GET /api/llm/workers', () => {
|
||||||
|
let app: express.Application;
|
||||||
|
let cm: ConfigManager;
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), 'llm-workers-api-'));
|
||||||
|
writeFileSync(join(tempDir, 'config.yaml'), [
|
||||||
|
'config_version: 2',
|
||||||
|
'llm:',
|
||||||
|
' workers:',
|
||||||
|
' - id: w-auto',
|
||||||
|
' connection_type: direct',
|
||||||
|
' endpoint: http://localhost:11434/v1',
|
||||||
|
' model: auto-model',
|
||||||
|
' roles: [auto, fast]',
|
||||||
|
' reasoning_efforts: [low, high]',
|
||||||
|
' api_key: super-secret-key',
|
||||||
|
' extra_body:',
|
||||||
|
" foo: 'bar'",
|
||||||
|
" reasoning_effort: 'max'",
|
||||||
|
' enabled: true',
|
||||||
|
' - id: w-title-only',
|
||||||
|
' connection_type: direct',
|
||||||
|
' endpoint: http://localhost:11435/v1',
|
||||||
|
' model: title-model',
|
||||||
|
' roles: [title]',
|
||||||
|
' enabled: true',
|
||||||
|
' - id: w-reflection-only',
|
||||||
|
' connection_type: direct',
|
||||||
|
' endpoint: http://localhost:11436/v1',
|
||||||
|
' model: reflection-model',
|
||||||
|
' roles: [reflection]',
|
||||||
|
' enabled: true',
|
||||||
|
' - id: w-quality-disabled',
|
||||||
|
' connection_type: direct',
|
||||||
|
' endpoint: http://localhost:11437/v1',
|
||||||
|
' model: quality-model',
|
||||||
|
' roles: [quality]',
|
||||||
|
' vlm: true',
|
||||||
|
' enabled: false',
|
||||||
|
].join('\n'));
|
||||||
|
cm = new ConfigManager(join(tempDir, 'config.yaml'));
|
||||||
|
app = express();
|
||||||
|
app.use('/api/llm/workers', createLlmWorkersRouter(cm));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns only execution-role workers, in config order', async () => {
|
||||||
|
const res = await request(app).get('/api/llm/workers');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const ids = res.body.workers.map((w: { id: string }) => w.id);
|
||||||
|
expect(ids).toEqual(['w-auto', 'w-quality-disabled']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('response objects contain exactly the 6 documented fields', async () => {
|
||||||
|
const res = await request(app).get('/api/llm/workers');
|
||||||
|
const w = res.body.workers.find((x: { id: string }) => x.id === 'w-auto');
|
||||||
|
expect(Object.keys(w).sort()).toEqual(
|
||||||
|
['enabled', 'id', 'model', 'reasoningEfforts', 'roles', 'vlm'].sort(),
|
||||||
|
);
|
||||||
|
expect(w).toMatchObject({
|
||||||
|
id: 'w-auto',
|
||||||
|
model: 'auto-model',
|
||||||
|
roles: ['auto', 'fast'],
|
||||||
|
reasoningEfforts: ['low', 'high'],
|
||||||
|
vlm: false,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects enabled:false and vlm:true correctly', async () => {
|
||||||
|
const res = await request(app).get('/api/llm/workers');
|
||||||
|
const w = res.body.workers.find((x: { id: string }) => x.id === 'w-quality-disabled');
|
||||||
|
expect(w).toMatchObject({ enabled: false, vlm: true, roles: ['quality'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NEVER leaks endpoint / apiKey / extraBody (secret regression guard)', async () => {
|
||||||
|
const res = await request(app).get('/api/llm/workers');
|
||||||
|
const raw = JSON.stringify(res.body);
|
||||||
|
expect(raw).not.toContain('endpoint');
|
||||||
|
expect(raw).not.toContain('apiKey');
|
||||||
|
expect(raw).not.toContain('extraBody');
|
||||||
|
// Sentinel values that WOULD serialize if the corresponding field leaked.
|
||||||
|
// w-auto seeds a real api_key AND a real extra_body ({foo:'bar', ...}); a
|
||||||
|
// non-empty extraBody is what makes the extraBody guard non-vacuous (an
|
||||||
|
// undefined value would be dropped by JSON.stringify and pass trivially).
|
||||||
|
expect(raw).not.toContain('super-secret-key'); // api_key sentinel
|
||||||
|
expect(raw).not.toContain('foo'); // extra_body sentinel
|
||||||
|
const wAuto = res.body.workers.find((x: { id: string }) => x.id === 'w-auto');
|
||||||
|
expect(wAuto).not.toHaveProperty('extraBody');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes title-only and reflection-only workers entirely', async () => {
|
||||||
|
const res = await request(app).get('/api/llm/workers');
|
||||||
|
const ids = res.body.workers.map((w: { id: string }) => w.id);
|
||||||
|
expect(ids).not.toContain('w-title-only');
|
||||||
|
expect(ids).not.toContain('w-reflection-only');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// requireAuth gating (401 when auth is active) is wired in auth-subsystem.ts as
|
||||||
|
// `app.use('/api/llm/workers', requireAuth)`, mirroring the shared pattern used by
|
||||||
|
// every other /api/* namespace (see auth-subsystem.ts). That middleware is exercised
|
||||||
|
// by the auth-subsystem integration tests; here we confirm the no-auth path (the
|
||||||
|
// router mounted standalone, as server.ts does when auth is inactive) returns 200.
|
||||||
|
describe('GET /api/llm/workers — no-auth mode', () => {
|
||||||
|
it('returns 200 without any auth middleware in front', async () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'llm-workers-api-noauth-'));
|
||||||
|
writeFileSync(join(tempDir, 'config.yaml'), [
|
||||||
|
'config_version: 2',
|
||||||
|
'llm:',
|
||||||
|
' workers:',
|
||||||
|
' - id: w1',
|
||||||
|
' connection_type: direct',
|
||||||
|
' endpoint: http://localhost:11434/v1',
|
||||||
|
' model: m',
|
||||||
|
' roles: [auto]',
|
||||||
|
' enabled: true',
|
||||||
|
].join('\n'));
|
||||||
|
const cm2 = new ConfigManager(join(tempDir, 'config.yaml'));
|
||||||
|
const app2 = express();
|
||||||
|
app2.use('/api/llm/workers', createLlmWorkersRouter(cm2));
|
||||||
|
const res = await request(app2).get('/api/llm/workers');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.workers).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateLlmSelection', () => {
|
||||||
|
const workers: WorkerDef[] = [
|
||||||
|
{ id: 'w-auto', endpoint: 'x', model: 'm', roles: ['auto'], reasoningEfforts: ['low', 'high'], enabled: true },
|
||||||
|
{ id: 'w-disabled', endpoint: 'x', model: 'm', roles: ['auto'], enabled: false },
|
||||||
|
{ id: 'w-title-only', endpoint: 'x', model: 'm', roles: ['title'], enabled: true },
|
||||||
|
{ id: 'w-no-efforts', endpoint: 'x', model: 'm', roles: ['fast'], enabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('both null/undefined → ok (clearing selection)', () => {
|
||||||
|
expect(validateLlmSelection(workers, null, null)).toEqual({ ok: true });
|
||||||
|
expect(validateLlmSelection(workers, undefined, undefined)).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('effort provided without workerId → error', () => {
|
||||||
|
const res = validateLlmSelection(workers, null, 'high');
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.error).toContain('ワーカー指定');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('workerId provided but not found in list → error', () => {
|
||||||
|
const res = validateLlmSelection(workers, 'does-not-exist', null);
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.error).toContain('存在しません');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matching worker exists but disabled → error', () => {
|
||||||
|
const res = validateLlmSelection(workers, 'w-disabled', null);
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.error).toContain('無効化');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matching worker exists but has no execution role → error', () => {
|
||||||
|
const res = validateLlmSelection(workers, 'w-title-only', null);
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.error).toContain('実行できません');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('effort not in worker reasoningEfforts → error', () => {
|
||||||
|
const res = validateLlmSelection(workers, 'w-auto', 'max');
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.error).toContain('対応していません');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('effort provided but worker declares no reasoningEfforts at all → error', () => {
|
||||||
|
const res = validateLlmSelection(workers, 'w-no-efforts', 'low');
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.error).toContain('対応していません');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('workerId only (no effort), valid + enabled + execution role → ok', () => {
|
||||||
|
expect(validateLlmSelection(workers, 'w-auto', null)).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('workerId + valid effort → ok', () => {
|
||||||
|
expect(validateLlmSelection(workers, 'w-auto', 'high')).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
108
src/bridge/llm-workers-api.ts
Normal file
108
src/bridge/llm-workers-api.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import type { ConfigManager } from '../config-manager.js';
|
||||||
|
import type { WorkerDef } from '../config.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 1 (LLM 選択): GET /api/llm/workers — タスクにピン留め可能なワーカー一覧。
|
||||||
|
*
|
||||||
|
* config.provider.workers から、実行ロール(auto|fast|quality)を1つ以上持つ
|
||||||
|
* ワーカーだけを config 順で返す。title/reflection 専用ワーカーはジョブを
|
||||||
|
* 実行できないため、選択肢から除外する。
|
||||||
|
*
|
||||||
|
* レスポンスは endpoint / apiKey / extraBody を絶対に含めない(UI に渡す
|
||||||
|
* 必要がない秘密値・内部接続先のため)。GET /api/workers(config-api.ts)は
|
||||||
|
* 管理系ダッシュボード向けで endpoint を返すが、こちらは選択 UI 専用の
|
||||||
|
* 別エンドポイント。
|
||||||
|
*
|
||||||
|
* requireAuth は auth-subsystem.ts の `app.use('/api/llm/workers', requireAuth)`
|
||||||
|
* (認証有効時のみ登録)が担う。このルーター自身は認証状態を意識しない。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** タスクにピン留めしてジョブを実行できるロール。title/reflection 専用は含まない。 */
|
||||||
|
const PINNABLE_EXECUTION_ROLES = new Set(['auto', 'fast', 'quality']);
|
||||||
|
|
||||||
|
/** roles が未設定/空の場合は全実行ロールを持つ扱い(config.ts の normalizeWorkerDefs の既定と揃える)。 */
|
||||||
|
export function hasPinnableExecutionRole(worker: WorkerDef): boolean {
|
||||||
|
if (!Array.isArray(worker.roles) || worker.roles.length === 0) return true;
|
||||||
|
return worker.roles.some((r) => PINNABLE_EXECUTION_ROLES.has(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LlmWorkerListItem {
|
||||||
|
id: string;
|
||||||
|
model: string;
|
||||||
|
roles: string[];
|
||||||
|
reasoningEfforts: string[];
|
||||||
|
vlm: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toListItem(w: WorkerDef): LlmWorkerListItem {
|
||||||
|
return {
|
||||||
|
id: w.id,
|
||||||
|
model: w.model ?? '',
|
||||||
|
roles: Array.isArray(w.roles) ? w.roles : [],
|
||||||
|
reasoningEfforts: Array.isArray(w.reasoningEfforts) ? w.reasoningEfforts : [],
|
||||||
|
vlm: w.vlm === true,
|
||||||
|
enabled: w.enabled !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLlmWorkersRouter(configManager: ConfigManager): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const cfg = configManager.getConfig();
|
||||||
|
const workers = (cfg.provider?.workers ?? [])
|
||||||
|
.filter(hasPinnableExecutionRole)
|
||||||
|
.map(toListItem);
|
||||||
|
res.json({ workers });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`[llm-workers-api] GET / failed: ${String(e)}`);
|
||||||
|
res.status(500).json({ error: 'ワーカー一覧の取得に失敗しました' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* タスクへの LLM ワーカー / reasoning effort ピン留めが妥当かを検証する純関数。
|
||||||
|
* config アクセスは呼び出し側の責務(workers は呼び出し側が渡す)— Task 11
|
||||||
|
* の task API・Task 13/14 の UI どちらからも import できるようにするため。
|
||||||
|
*/
|
||||||
|
export function validateLlmSelection(
|
||||||
|
workers: WorkerDef[],
|
||||||
|
workerId: string | null | undefined,
|
||||||
|
effort: string | null | undefined,
|
||||||
|
): { ok: true } | { ok: false; error: string } {
|
||||||
|
const hasWorkerId = workerId !== null && workerId !== undefined && workerId !== '';
|
||||||
|
const hasEffort = effort !== null && effort !== undefined && effort !== '';
|
||||||
|
|
||||||
|
if (!hasWorkerId && !hasEffort) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
if (hasEffort && !hasWorkerId) {
|
||||||
|
return { ok: false, error: 'reasoning effort はワーカー指定とセットの場合のみ指定できます' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = workers.find((w) => w.id === workerId);
|
||||||
|
if (!worker) {
|
||||||
|
return { ok: false, error: '指定されたワーカーは存在しません' };
|
||||||
|
}
|
||||||
|
if (worker.enabled === false) {
|
||||||
|
return { ok: false, error: '指定されたワーカーは無効化されています' };
|
||||||
|
}
|
||||||
|
if (!hasPinnableExecutionRole(worker)) {
|
||||||
|
return { ok: false, error: 'このワーカーはジョブを実行できません' };
|
||||||
|
}
|
||||||
|
if (hasEffort) {
|
||||||
|
const efforts = Array.isArray(worker.reasoningEfforts) ? worker.reasoningEfforts : [];
|
||||||
|
if (!efforts.includes(effort as string)) {
|
||||||
|
return { ok: false, error: 'このワーカーは指定の reasoning effort に対応していません' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@ -1289,6 +1289,18 @@ describe('POST /api/local/tasks/:id/continue', () => {
|
|||||||
expect(res.body.error).toBe('piece_required');
|
expect(res.body.error).toBe('piece_required');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('continuation carries over the task-level LLM pin (llmWorkerId/llmEffort) to the new job', async () => {
|
||||||
|
const { task } = await setupTaskWithTerminalJob();
|
||||||
|
await repo.updateLocalTask(task.id, { llmWorkerId: 'worker-gpu-1', llmEffort: 'high' } as any);
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/local/tasks/${task.id}/continue`)
|
||||||
|
.send({ piece: 'ssh-ops', instruction: 'go with pin' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const newJob = await repo.getJob(res.body.jobId);
|
||||||
|
expect(newJob?.requiredWorkerId).toBe('worker-gpu-1');
|
||||||
|
expect(newJob?.reasoningEffort).toBe('high');
|
||||||
|
});
|
||||||
|
|
||||||
it('all DB-valid terminal states allow continuation', async () => {
|
it('all DB-valid terminal states allow continuation', async () => {
|
||||||
// jobs.status CHECK constraint permits these four terminal states.
|
// jobs.status CHECK constraint permits these four terminal states.
|
||||||
// 'aborted' is intentionally absent — the worker maps abort outcomes to
|
// 'aborted' is intentionally absent — the worker maps abort outcomes to
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { registerLocalTaskToolRequestRoutes } from './local-tasks-tool-requests-
|
|||||||
import { registerLocalTaskPackageRequestRoutes } from './local-tasks-package-requests-api.js';
|
import { registerLocalTaskPackageRequestRoutes } from './local-tasks-package-requests-api.js';
|
||||||
import { registerLocalTaskControlRoutes } from './local-tasks-control-api.js';
|
import { registerLocalTaskControlRoutes } from './local-tasks-control-api.js';
|
||||||
import { registerLocalTaskStreamRoutes } from './local-tasks-stream-api.js';
|
import { registerLocalTaskStreamRoutes } from './local-tasks-stream-api.js';
|
||||||
|
import { registerLocalTaskMovementHistoryRoutes } from './local-tasks-movement-history-api.js';
|
||||||
|
|
||||||
export interface LocalTasksApiOptions {
|
export interface LocalTasksApiOptions {
|
||||||
repo: Repository;
|
repo: Repository;
|
||||||
@ -69,6 +70,15 @@ export interface LocalTasksApiOptions {
|
|||||||
* approving a package request fails with 400 (feature disabled).
|
* approving a package request fails with 400 (feature disabled).
|
||||||
*/
|
*/
|
||||||
getPythonPackagesConfig?: () => import('../config.js').PythonPackagesConfigShape | undefined;
|
getPythonPackagesConfig?: () => import('../config.js').PythonPackagesConfigShape | undefined;
|
||||||
|
/**
|
||||||
|
* LLM 選択 Phase 1: タスク作成 / PATCH で llmWorkerId・llmEffort を
|
||||||
|
* validateLlmSelection (llm-workers-api.ts) にかける際の worker 一覧。
|
||||||
|
* Called per request so config changes take effect without a restart.
|
||||||
|
* When unset, validation runs against an empty worker list (=常に
|
||||||
|
* 「指定されたワーカーは存在しません」で拒否 — worker 選択 UI が無い
|
||||||
|
* 旧来のテスト/デプロイでは llmWorkerId/llmEffort を送らない前提)。
|
||||||
|
*/
|
||||||
|
getLlmWorkers?: () => import('../config.js').WorkerDef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -98,4 +108,5 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
|||||||
registerLocalTaskCommentsRoutes(app, deps);
|
registerLocalTaskCommentsRoutes(app, deps);
|
||||||
registerLocalTaskControlRoutes(app, deps);
|
registerLocalTaskControlRoutes(app, deps);
|
||||||
registerLocalTaskStreamRoutes(app, deps);
|
registerLocalTaskStreamRoutes(app, deps);
|
||||||
|
registerLocalTaskMovementHistoryRoutes(app, deps);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -204,6 +204,13 @@ export function registerLocalTaskCommentsRoutes(app: Application, deps: LocalTas
|
|||||||
visibilityScopeOrgId: task!.visibilityScopeOrgId,
|
visibilityScopeOrgId: task!.visibilityScopeOrgId,
|
||||||
browserSessionProfileId: task!.browserSessionProfileId ?? null,
|
browserSessionProfileId: task!.browserSessionProfileId ?? null,
|
||||||
spaceId: task!.spaceId ?? null,
|
spaceId: task!.spaceId ?? null,
|
||||||
|
// LLM 選択 Phase 1: タスクの sticky pin をフォローアップコメントで
|
||||||
|
// 生成される job にも引き継ぐ。PATCH 経由の変更は
|
||||||
|
// updateJobsLlmSelectionForTask が既存 queued/retry job に反映するが、
|
||||||
|
// ここでの createJobIfNoPending は「新規生成 or まだ pending が無い」
|
||||||
|
// ケースを担うので、task の現在値をそのまま渡す。
|
||||||
|
requiredWorkerId: task!.llmWorkerId ?? null,
|
||||||
|
reasoningEffort: task!.llmEffort ?? null,
|
||||||
}, { blockOnToolRequestPause: true });
|
}, { blockOnToolRequestPause: true });
|
||||||
// Blocked by a tool/package-approval pause → return BEFORE persisting the
|
// Blocked by a tool/package-approval pause → return BEFORE persisting the
|
||||||
// comment so a rejected post leaves no orphan comment tied to no job.
|
// comment so a rejected post leaves no orphan comment tied to no job.
|
||||||
|
|||||||
@ -114,6 +114,11 @@ export function registerLocalTaskControlRoutes(app: Application, deps: LocalTask
|
|||||||
visibilityScopeOrgId: task!.visibilityScopeOrgId,
|
visibilityScopeOrgId: task!.visibilityScopeOrgId,
|
||||||
browserSessionProfileId: task!.browserSessionProfileId ?? null,
|
browserSessionProfileId: task!.browserSessionProfileId ?? null,
|
||||||
spaceId: task!.spaceId ?? null,
|
spaceId: task!.spaceId ?? null,
|
||||||
|
// タスクの sticky pin を継承する(comment 経路と同じくタスクを正とする)。
|
||||||
|
// これを渡さないと、pin 済みタスクを継続した瞬間に profile ルーティングへ
|
||||||
|
// 戻ってしまう (codex P1)。
|
||||||
|
requiredWorkerId: task!.llmWorkerId ?? null,
|
||||||
|
reasoningEffort: task!.llmEffort ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await repo.updateLocalTask(taskId, { pieceName: piece });
|
await repo.updateLocalTask(taskId, { pieceName: piece });
|
||||||
|
|||||||
384
src/bridge/local-tasks-crud-api.llm-selection.test.ts
Normal file
384
src/bridge/local-tasks-crud-api.llm-selection.test.ts
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
/**
|
||||||
|
* PATCH /api/local/tasks/:id + POST /api/local/tasks — LLM 選択 Phase 1
|
||||||
|
* (Task 11): sticky worker/effort の検証・永続化・queued/retry job 反映、
|
||||||
|
* および作成時の atomicity(不正なら task 行そのものが作られない)。
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1) PATCH llmWorkerId=実在 worker → 200、task に保存、queued job に伝播
|
||||||
|
* 2) PATCH llmWorkerId=null → 解除、queued job も NULL に戻る
|
||||||
|
* 3) PATCH llmEffort のみ(既存 llmWorkerId とペアで検証)→ 200
|
||||||
|
* 4) PATCH llmEffort のみ(workerId 未設定タスク)→ 400
|
||||||
|
* 5) PATCH llmWorkerId=title専用ワーカー → 400
|
||||||
|
* 6) PATCH llmEffort=宣言外の値 → 400
|
||||||
|
* 7) タスク作成 POST で不正 workerId → 400 かつタスク行が作られない(atomicity)
|
||||||
|
* 8) コメント POST → 生成 job に task の sticky が乗る
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { mkdtempSync, rmSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { Repository, localTaskRepoName } from '../db/repository.js';
|
||||||
|
import { mountLocalTasksApi } from './local-tasks-api.js';
|
||||||
|
import type { WorkerDef } from '../config.js';
|
||||||
|
|
||||||
|
const WORKERS: WorkerDef[] = [
|
||||||
|
{
|
||||||
|
id: 'w1',
|
||||||
|
endpoint: 'http://localhost:11434/v1',
|
||||||
|
model: 'w1-model',
|
||||||
|
roles: ['auto', 'fast'],
|
||||||
|
reasoningEfforts: ['low', 'high'],
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wt',
|
||||||
|
endpoint: 'http://localhost:11435/v1',
|
||||||
|
model: 'wt-model',
|
||||||
|
roles: ['title'],
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('PATCH /api/local/tasks/:id — llmWorkerId/llmEffort sticky selection', () => {
|
||||||
|
let tempDir = '';
|
||||||
|
let repo: Repository;
|
||||||
|
let app: express.Application;
|
||||||
|
let aliceUser: Express.User;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), 'lt-llm-sel-'));
|
||||||
|
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||||
|
const real = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
|
||||||
|
aliceUser = { ...real, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
(req as unknown as { user: Express.User }).user = aliceUser;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
mountLocalTasksApi(app, {
|
||||||
|
repo,
|
||||||
|
worktreeDir: join(tempDir, 'workspaces'),
|
||||||
|
getLlmWorkers: () => WORKERS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
repo.close();
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function makeTaskWithQueuedJob(): Promise<{ taskId: number; jobId: string }> {
|
||||||
|
const task = await repo.createLocalTask({
|
||||||
|
title: 't', body: 'b', ownerId: aliceUser.id, visibility: 'private',
|
||||||
|
workspacePath: join(tempDir, 'ws'),
|
||||||
|
});
|
||||||
|
const job = await repo.createJob({
|
||||||
|
repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go',
|
||||||
|
ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null,
|
||||||
|
});
|
||||||
|
return { taskId: task.id, jobId: job.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredWorkerIdOf(jobId: string): string | null {
|
||||||
|
const row = repo.getDb()
|
||||||
|
.prepare('SELECT required_worker_id FROM jobs WHERE id = ?')
|
||||||
|
.get(jobId) as { required_worker_id: string | null };
|
||||||
|
return row.required_worker_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('1) sets llmWorkerId, persists on the task, and propagates to a queued job', async () => {
|
||||||
|
const { taskId, jobId } = await makeTaskWithQueuedJob();
|
||||||
|
const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: 'w1' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.task.llmWorkerId).toBe('w1');
|
||||||
|
expect(requiredWorkerIdOf(jobId)).toBe('w1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2) clearing llmWorkerId with explicit null reverts the queued job to NULL', async () => {
|
||||||
|
const { taskId, jobId } = await makeTaskWithQueuedJob();
|
||||||
|
await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: 'w1' });
|
||||||
|
expect(requiredWorkerIdOf(jobId)).toBe('w1');
|
||||||
|
|
||||||
|
const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: null });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.task.llmWorkerId).toBeNull();
|
||||||
|
expect(requiredWorkerIdOf(jobId)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2b) clearing llmWorkerId alone (no llmEffort in the body) while llmEffort is still set on the task also clears the effort (asymmetric-clear hardening)', async () => {
|
||||||
|
// Given: worker + effort が両方セット済み(non-UI クライアントが片方だけ
|
||||||
|
// 送ってくるケースを再現 — 一次 UI は常にペアで送るが A2A/bench/curl はそうとは限らない)。
|
||||||
|
const { taskId, jobId } = await makeTaskWithQueuedJob();
|
||||||
|
const setBoth = await request(app)
|
||||||
|
.patch(`/api/local/tasks/${taskId}`)
|
||||||
|
.send({ llmWorkerId: 'w1', llmEffort: 'high' });
|
||||||
|
expect(setBoth.status).toBe(200);
|
||||||
|
expect(requiredWorkerIdOf(jobId)).toBe('w1');
|
||||||
|
|
||||||
|
// When: llmWorkerId のみを null で送る(llmEffort フィールドは body に含めない)。
|
||||||
|
// 修正前は nextEffort が旧値 'high' のまま残り、effort-without-worker として 400 になっていた。
|
||||||
|
const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: null });
|
||||||
|
|
||||||
|
// Then: 200 で成功し、worker/effort の両方が null に揃って解除される。
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.task.llmWorkerId).toBeNull();
|
||||||
|
expect(res.body.task.llmEffort).toBeNull();
|
||||||
|
expect(requiredWorkerIdOf(jobId)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3) sets llmEffort alone, validated as a pair against the task\'s existing llmWorkerId', async () => {
|
||||||
|
const { taskId } = await makeTaskWithQueuedJob();
|
||||||
|
const setWorker = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: 'w1' });
|
||||||
|
expect(setWorker.status).toBe(200);
|
||||||
|
|
||||||
|
const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmEffort: 'high' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.task.llmWorkerId).toBe('w1');
|
||||||
|
expect(res.body.task.llmEffort).toBe('high');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('4) rejects llmEffort alone when the task has no llmWorkerId (pair invalid)', async () => {
|
||||||
|
const { taskId } = await makeTaskWithQueuedJob();
|
||||||
|
const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmEffort: 'high' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('5) rejects a title-only worker (no pinnable execution role)', async () => {
|
||||||
|
const { taskId } = await makeTaskWithQueuedJob();
|
||||||
|
const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: 'wt' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('6) rejects an effort not declared by the worker', async () => {
|
||||||
|
const { taskId } = await makeTaskWithQueuedJob();
|
||||||
|
const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: 'w1', llmEffort: 'bogus' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not touch llmWorkerId/llmEffort when neither field is present in the PATCH body', async () => {
|
||||||
|
const { taskId, jobId } = await makeTaskWithQueuedJob();
|
||||||
|
await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: 'w1', llmEffort: 'high' });
|
||||||
|
const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ title: 'renamed' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.task.llmWorkerId).toBe('w1');
|
||||||
|
expect(res.body.task.llmEffort).toBe('high');
|
||||||
|
expect(requiredWorkerIdOf(jobId)).toBe('w1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/local/tasks — llmWorkerId/llmEffort validated before insert (atomicity)', () => {
|
||||||
|
let tempDir = '';
|
||||||
|
let repo: Repository;
|
||||||
|
let app: express.Application;
|
||||||
|
let aliceUser: Express.User;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), 'lt-llm-sel-create-'));
|
||||||
|
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||||
|
const real = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
|
||||||
|
aliceUser = { ...real, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
(req as unknown as { user: Express.User }).user = aliceUser;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
mountLocalTasksApi(app, {
|
||||||
|
repo,
|
||||||
|
worktreeDir: join(tempDir, 'workspaces'),
|
||||||
|
getLlmWorkers: () => WORKERS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
repo.close();
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('7) rejects an unknown llmWorkerId with 400 and creates no task row', async () => {
|
||||||
|
const countBefore = (repo.getDb().prepare('SELECT COUNT(*) AS c FROM local_tasks').get() as { c: number }).c;
|
||||||
|
const res = await request(app).post('/api/local/tasks').send({
|
||||||
|
body: 'hello', piece: 'chat', llmWorkerId: 'does-not-exist',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const countAfter = (repo.getDb().prepare('SELECT COUNT(*) AS c FROM local_tasks').get() as { c: number }).c;
|
||||||
|
expect(countAfter).toBe(countBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a valid llmWorkerId/llmEffort pair at creation and stamps the spawned job', async () => {
|
||||||
|
const res = await request(app).post('/api/local/tasks').send({
|
||||||
|
body: 'hello', piece: 'chat', llmWorkerId: 'w1', llmEffort: 'low',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.task.llmWorkerId).toBe('w1');
|
||||||
|
expect(res.body.task.llmEffort).toBe('low');
|
||||||
|
const jobRow = repo.getDb()
|
||||||
|
.prepare('SELECT required_worker_id, reasoning_effort FROM jobs WHERE id = ?')
|
||||||
|
.get(res.body.jobId) as { required_worker_id: string | null; reasoning_effort: string | null };
|
||||||
|
expect(jobRow.required_worker_id).toBe('w1');
|
||||||
|
expect(jobRow.reasoning_effort).toBe('low');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /api/local/tasks/:id — llmWorkerId cascade reaches unstarted subtask descendants (codex P1)', () => {
|
||||||
|
let tempDir = '';
|
||||||
|
let repo: Repository;
|
||||||
|
let app: express.Application;
|
||||||
|
let aliceUser: Express.User;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), 'lt-llm-sel-subtask-'));
|
||||||
|
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||||
|
const real = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
|
||||||
|
aliceUser = { ...real, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
(req as unknown as { user: Express.User }).user = aliceUser;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
mountLocalTasksApi(app, {
|
||||||
|
repo,
|
||||||
|
worktreeDir: join(tempDir, 'workspaces'),
|
||||||
|
getLlmWorkers: () => WORKERS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
repo.close();
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function requiredWorkerIdOf(jobId: string): string | null {
|
||||||
|
const row = repo.getDb()
|
||||||
|
.prepare('SELECT required_worker_id FROM jobs WHERE id = ?')
|
||||||
|
.get(jobId) as { required_worker_id: string | null };
|
||||||
|
return row.required_worker_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('clears the pin on a queued subtask child while leaving the waiting_subtasks parent untouched', async () => {
|
||||||
|
// Given: タスク直下ジョブが 'w1' にピン留めされ waiting_subtasks で停車中
|
||||||
|
// (SpawnSubTask がピンを継承した queued の子を抱えている)。w1 が
|
||||||
|
// 停滞/削除されると子は永久に claim されず、親も永久に waiting_subtasks
|
||||||
|
// のまま — これが codex P1 のシナリオ。
|
||||||
|
const task = await repo.createLocalTask({
|
||||||
|
title: 't', body: 'b', ownerId: aliceUser.id, visibility: 'private',
|
||||||
|
workspacePath: join(tempDir, 'ws'), llmWorkerId: 'w1', llmEffort: 'high',
|
||||||
|
});
|
||||||
|
const rootJob = await repo.createJob({
|
||||||
|
repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go',
|
||||||
|
ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null,
|
||||||
|
requiredWorkerId: 'w1', reasoningEffort: 'high',
|
||||||
|
} as any);
|
||||||
|
await repo.updateJob(rootJob.id, { status: 'waiting_subtasks' });
|
||||||
|
|
||||||
|
const subtaskJob = await repo.createJob({
|
||||||
|
repo: `subtask/${rootJob.id}`, issueNumber: 1, instruction: 'sub work',
|
||||||
|
ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null,
|
||||||
|
requiredWorkerId: 'w1', reasoningEffort: 'high', parentJobId: rootJob.id,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// When: ユーザーがピンを解除する(UI の「自動に戻す」復旧操作)。
|
||||||
|
const res = await request(app).patch(`/api/local/tasks/${task.id}`).send({ llmWorkerId: null });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// Then: queued subtask child は復旧(NULL = auto)— これが修正前は
|
||||||
|
// 'w1' のまま残り、削除済みワーカーを永遠に待ち続けていた (RED)。
|
||||||
|
expect(requiredWorkerIdOf(subtaskJob.id)).toBeNull();
|
||||||
|
|
||||||
|
// And: waiting_subtasks の親自身は queued/retry ではないので不変
|
||||||
|
// (走行中扱いの行を横から書き換えない)。
|
||||||
|
const parentRow = repo.getDb()
|
||||||
|
.prepare('SELECT required_worker_id, status FROM jobs WHERE id = ?')
|
||||||
|
.get(rootJob.id) as { required_worker_id: string | null; status: string };
|
||||||
|
expect(parentRow.status).toBe('waiting_subtasks');
|
||||||
|
expect(parentRow.required_worker_id).toBe('w1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cascades through a 2-level-deep subtask (grandchild) as well', async () => {
|
||||||
|
const task = await repo.createLocalTask({
|
||||||
|
title: 't', body: 'b', ownerId: aliceUser.id, visibility: 'private',
|
||||||
|
workspacePath: join(tempDir, 'ws'), llmWorkerId: 'w1', llmEffort: 'high',
|
||||||
|
});
|
||||||
|
const rootJob = await repo.createJob({
|
||||||
|
repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go',
|
||||||
|
ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null,
|
||||||
|
requiredWorkerId: 'w1', reasoningEffort: 'high',
|
||||||
|
} as any);
|
||||||
|
await repo.updateJob(rootJob.id, { status: 'waiting_subtasks' });
|
||||||
|
|
||||||
|
const childJob = await repo.createJob({
|
||||||
|
repo: `subtask/${rootJob.id}`, issueNumber: 1, instruction: 'sub work',
|
||||||
|
ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null,
|
||||||
|
requiredWorkerId: 'w1', reasoningEffort: 'high', parentJobId: rootJob.id,
|
||||||
|
} as any);
|
||||||
|
await repo.updateJob(childJob.id, { status: 'waiting_subtasks' });
|
||||||
|
|
||||||
|
const grandchildJob = await repo.createJob({
|
||||||
|
repo: `subtask/${childJob.id}`, issueNumber: 1, instruction: 'sub sub work',
|
||||||
|
ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null,
|
||||||
|
requiredWorkerId: 'w1', reasoningEffort: 'high', parentJobId: childJob.id,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const res = await request(app).patch(`/api/local/tasks/${task.id}`).send({ llmWorkerId: null });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
expect(requiredWorkerIdOf(grandchildJob.id)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/local/tasks/:id/comments — inherits the task sticky selection into the spawned job', () => {
|
||||||
|
let tempDir = '';
|
||||||
|
let repo: Repository;
|
||||||
|
let app: express.Application;
|
||||||
|
let aliceUser: Express.User;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), 'lt-llm-sel-comment-'));
|
||||||
|
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||||
|
const real = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
|
||||||
|
aliceUser = { ...real, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
(req as unknown as { user: Express.User }).user = aliceUser;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
mountLocalTasksApi(app, {
|
||||||
|
repo,
|
||||||
|
worktreeDir: join(tempDir, 'workspaces'),
|
||||||
|
getLlmWorkers: () => WORKERS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
repo.close();
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('8) a comment on a task pinned to w1 spawns a job with required_worker_id=w1', async () => {
|
||||||
|
const task = await repo.createLocalTask({
|
||||||
|
title: 't', body: 'b', ownerId: aliceUser.id, visibility: 'private',
|
||||||
|
workspacePath: join(tempDir, 'ws'), llmWorkerId: 'w1', llmEffort: 'high',
|
||||||
|
});
|
||||||
|
// Prior job already terminal so the comment spawns a NEW job (not reuse).
|
||||||
|
const prev = await repo.createJob({
|
||||||
|
repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go',
|
||||||
|
ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null,
|
||||||
|
});
|
||||||
|
await repo.updateJob(prev.id, { status: 'succeeded' });
|
||||||
|
|
||||||
|
const res = await request(app).post(`/api/local/tasks/${task.id}/comments`).send({ body: 'again', author: 'user' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.jobId).not.toBe(prev.id);
|
||||||
|
|
||||||
|
const jobRow = repo.getDb()
|
||||||
|
.prepare('SELECT required_worker_id, reasoning_effort FROM jobs WHERE id = ?')
|
||||||
|
.get(res.body.jobId) as { required_worker_id: string | null; reasoning_effort: string | null };
|
||||||
|
expect(jobRow.required_worker_id).toBe('w1');
|
||||||
|
expect(jobRow.reasoning_effort).toBe('high');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -10,6 +10,7 @@ import { buildTitleFallback } from '../title-generation.js';
|
|||||||
import { resolveTaskWorkspaceDir, ensureWorkspaceDirs } from '../spaces/workspace-resolver.js';
|
import { resolveTaskWorkspaceDir, ensureWorkspaceDirs } from '../spaces/workspace-resolver.js';
|
||||||
import { spaceRunsDir } from '../spaces/runtime-paths.js';
|
import { spaceRunsDir } from '../spaces/runtime-paths.js';
|
||||||
import { type LocalTasksDeps, makeDynamicJson } from './local-tasks-shared.js';
|
import { type LocalTasksDeps, makeDynamicJson } from './local-tasks-shared.js';
|
||||||
|
import { validateLlmSelection } from './llm-workers-api.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core task lifecycle CRUD: list / create / detail / patch / delete.
|
* Core task lifecycle CRUD: list / create / detail / patch / delete.
|
||||||
@ -82,6 +83,27 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe
|
|||||||
browserSessionProfileId = n;
|
browserSessionProfileId = n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LLM 選択 Phase 1: タスク作成時の pin。CreateTaskDialog (Task 13) が
|
||||||
|
// llmWorkerId/llmEffort を送る想定。insert前にvalidateLlmSelectionで
|
||||||
|
// 検証し、不正なら「タスクは作られない」原子性を保つ(fail-before-insert)。
|
||||||
|
const rawLlmWorkerId = req.body?.llmWorkerId;
|
||||||
|
const rawLlmEffort = req.body?.llmEffort;
|
||||||
|
if (rawLlmWorkerId !== undefined && rawLlmWorkerId !== null && typeof rawLlmWorkerId !== 'string') {
|
||||||
|
res.status(400).json({ error: 'llmWorkerId must be a string or null' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rawLlmEffort !== undefined && rawLlmEffort !== null && typeof rawLlmEffort !== 'string') {
|
||||||
|
res.status(400).json({ error: 'llmEffort must be a string or null' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const llmWorkerId: string | null = (typeof rawLlmWorkerId === 'string' && rawLlmWorkerId.trim() !== '') ? rawLlmWorkerId : null;
|
||||||
|
const llmEffort: string | null = (typeof rawLlmEffort === 'string' && rawLlmEffort.trim() !== '') ? rawLlmEffort : null;
|
||||||
|
const llmValidation = validateLlmSelection(opts.getLlmWorkers?.() ?? [], llmWorkerId, llmEffort);
|
||||||
|
if (!llmValidation.ok) {
|
||||||
|
res.status(400).json({ error: llmValidation.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const userTitle = (body.title ?? '').trim();
|
const userTitle = (body.title ?? '').trim();
|
||||||
const rawPiece = (body.piece ?? 'auto').trim();
|
const rawPiece = (body.piece ?? 'auto').trim();
|
||||||
const attachmentNames = (body.attachments ?? []).map((a: { name?: string }) => a.name).filter(Boolean) as string[];
|
const attachmentNames = (body.attachments ?? []).map((a: { name?: string }) => a.name).filter(Boolean) as string[];
|
||||||
@ -153,6 +175,8 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe
|
|||||||
visibilityScopeOrgId: effectiveScopeOrgId,
|
visibilityScopeOrgId: effectiveScopeOrgId,
|
||||||
browserSessionProfileId,
|
browserSessionProfileId,
|
||||||
options: taskOptions,
|
options: taskOptions,
|
||||||
|
llmWorkerId,
|
||||||
|
llmEffort,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 実効スペースを解決し、mode に応じてワークスペースパスを決める。
|
// 実効スペースを解決し、mode に応じてワークスペースパスを決める。
|
||||||
@ -224,6 +248,8 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe
|
|||||||
browserSessionProfileId: task.browserSessionProfileId ?? null,
|
browserSessionProfileId: task.browserSessionProfileId ?? null,
|
||||||
spaceId: task.spaceId ?? null,
|
spaceId: task.spaceId ?? null,
|
||||||
payload: hasOptions ? JSON.stringify({ options: taskOptions }) : undefined,
|
payload: hasOptions ? JSON.stringify({ options: taskOptions }) : undefined,
|
||||||
|
requiredWorkerId: task.llmWorkerId ?? null,
|
||||||
|
reasoningEffort: task.llmEffort ?? null,
|
||||||
});
|
});
|
||||||
await repo.addAuditLog(job.id, 'job_queued_local_create', 'local-ui', { taskId: task.id });
|
await repo.addAuditLog(job.id, 'job_queued_local_create', 'local-ui', { taskId: task.id });
|
||||||
|
|
||||||
@ -265,7 +291,14 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe
|
|||||||
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
|
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
|
||||||
if (!checkTaskOwnership(req, res, task)) return;
|
if (!checkTaskOwnership(req, res, task)) return;
|
||||||
|
|
||||||
const updates: { title?: string; titleSource?: 'user'; visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null } = {};
|
const updates: {
|
||||||
|
title?: string;
|
||||||
|
titleSource?: 'user';
|
||||||
|
visibility?: 'private' | 'org' | 'public';
|
||||||
|
visibilityScopeOrgId?: string | null;
|
||||||
|
llmWorkerId?: string | null;
|
||||||
|
llmEffort?: string | null;
|
||||||
|
} = {};
|
||||||
if (req.body.title !== undefined) {
|
if (req.body.title !== undefined) {
|
||||||
if (typeof req.body.title !== 'string') {
|
if (typeof req.body.title !== 'string') {
|
||||||
res.status(400).json({ error: 'title must be a string' }); return;
|
res.status(400).json({ error: 'title must be a string' }); return;
|
||||||
@ -298,6 +331,46 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe
|
|||||||
if (updates.visibility && updates.visibility !== 'org') {
|
if (updates.visibility && updates.visibility !== 'org') {
|
||||||
updates.visibilityScopeOrgId = null;
|
updates.visibilityScopeOrgId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LLM 選択 Phase 1: sticky worker/effort の部分 PATCH。片方だけ送られた
|
||||||
|
// 場合は、もう片方をタスクの既存値と組み合わせて「ペアとして」検証する
|
||||||
|
// (例: effort だけ送ると既に pin 済みの workerId と組み合わせて妥当性を
|
||||||
|
// 見る — effort 単体の妥当性は worker 抜きでは判定できないため)。
|
||||||
|
// 明示 null は解除(ワーカー未選択に戻し、従来の profile ルーティングへ)。
|
||||||
|
const hasWorkerIdField = Object.prototype.hasOwnProperty.call(req.body ?? {}, 'llmWorkerId');
|
||||||
|
const hasEffortField = Object.prototype.hasOwnProperty.call(req.body ?? {}, 'llmEffort');
|
||||||
|
if (hasWorkerIdField || hasEffortField) {
|
||||||
|
if (hasWorkerIdField && req.body.llmWorkerId !== null && typeof req.body.llmWorkerId !== 'string') {
|
||||||
|
res.status(400).json({ error: 'llmWorkerId must be a string or null' }); return;
|
||||||
|
}
|
||||||
|
if (hasEffortField && req.body.llmEffort !== null && typeof req.body.llmEffort !== 'string') {
|
||||||
|
res.status(400).json({ error: 'llmEffort must be a string or null' }); return;
|
||||||
|
}
|
||||||
|
const nextWorkerId: string | null = hasWorkerIdField
|
||||||
|
? ((typeof req.body.llmWorkerId === 'string' && req.body.llmWorkerId.trim() !== '') ? req.body.llmWorkerId : null)
|
||||||
|
: (task!.llmWorkerId ?? null);
|
||||||
|
let nextEffort: string | null = hasEffortField
|
||||||
|
? ((typeof req.body.llmEffort === 'string' && req.body.llmEffort.trim() !== '') ? req.body.llmEffort : null)
|
||||||
|
: (task!.llmEffort ?? null);
|
||||||
|
// ワーカーが「このリクエストで」明示的に null 指定された場合(解除)、
|
||||||
|
// 既存の effort が残っていると「effort だけがワーカー無しで残る」不整合
|
||||||
|
// な組み合わせになり、後段の validateLlmSelection が effort-without-worker
|
||||||
|
// として 400 を返してしまう。ワーカー解除は常に成功すべき操作なので、
|
||||||
|
// effort も道連れで null にする(バリデーション前)。
|
||||||
|
// 注意: hasWorkerIdField で「このリクエストが worker フィールドに触れたか」
|
||||||
|
// を見る必要がある —— worker フィールド自体が未送信で、たまたま既存の
|
||||||
|
// task に worker が無い(かつ effort だけを新規に送った)ケースは
|
||||||
|
// 「clear」ではなく単なる effort-without-worker の不正入力なので、
|
||||||
|
// 引き続き 400 になるべき(ケース4のテストが固定している)。
|
||||||
|
if (hasWorkerIdField && nextWorkerId == null) nextEffort = null;
|
||||||
|
const llmValidation = validateLlmSelection(opts.getLlmWorkers?.() ?? [], nextWorkerId, nextEffort);
|
||||||
|
if (!llmValidation.ok) {
|
||||||
|
res.status(400).json({ error: llmValidation.error }); return;
|
||||||
|
}
|
||||||
|
updates.llmWorkerId = nextWorkerId;
|
||||||
|
updates.llmEffort = nextEffort;
|
||||||
|
}
|
||||||
|
|
||||||
await repo.updateLocalTask(taskId, updates);
|
await repo.updateLocalTask(taskId, updates);
|
||||||
const refreshed = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
|
const refreshed = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
|
||||||
if ((updates.visibility !== undefined || updates.visibilityScopeOrgId !== undefined) && refreshed) {
|
if ((updates.visibility !== undefined || updates.visibilityScopeOrgId !== undefined) && refreshed) {
|
||||||
@ -306,6 +379,16 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe
|
|||||||
visibilityScopeOrgId: refreshed.visibilityScopeOrgId ?? null,
|
visibilityScopeOrgId: refreshed.visibilityScopeOrgId ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Sticky ジョブ反映: PATCH 直前に生成された queued/retry ジョブ(例えば
|
||||||
|
// 数瞬前のコメントで作られた分)が古い(または未選択の)pin を持ったまま
|
||||||
|
// にならないよう、確定した選択を即座に伝播する。ワーカーが消えた場合の
|
||||||
|
// 解除(null)も同じ経路で待機中ジョブへ届き、通常ルーティングに戻る。
|
||||||
|
if ((updates.llmWorkerId !== undefined || updates.llmEffort !== undefined) && refreshed) {
|
||||||
|
await repo.updateJobsLlmSelectionForTask(taskId, {
|
||||||
|
workerId: refreshed.llmWorkerId ?? null,
|
||||||
|
effort: refreshed.llmEffort ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
res.json({ task: refreshed });
|
res.json({ task: refreshed });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`Patch local task API error: ${err}`);
|
logger.error(`Patch local task API error: ${err}`);
|
||||||
|
|||||||
47
src/bridge/local-tasks-movement-history-api.test.ts
Normal file
47
src/bridge/local-tasks-movement-history-api.test.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { readMovementHistory } from './local-tasks-movement-history-api.js';
|
||||||
|
|
||||||
|
const dirs: string[] = [];
|
||||||
|
|
||||||
|
const event = (kind: string, payload: Record<string, unknown>, seq: number) => JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
ts: `2026-07-12T00:00:0${seq}.000Z`,
|
||||||
|
seq,
|
||||||
|
eventId: `e${seq}`,
|
||||||
|
runId: 'run-1',
|
||||||
|
movement: 'verify',
|
||||||
|
iteration: 2,
|
||||||
|
kind,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readMovementHistory', () => {
|
||||||
|
it('returns only movement events and strips free-form retry reasons', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'movement-history-'));
|
||||||
|
dirs.push(dir);
|
||||||
|
const path = join(dir, 'events.jsonl');
|
||||||
|
writeFileSync(path, [
|
||||||
|
event('movement_start', { instruction: 'secret' }, 1),
|
||||||
|
event('tool_call', { args: { token: 'secret' } }, 2),
|
||||||
|
event('llm_call_retry', { attempt: 2, maxAttempts: 3, reason: 'Bearer secret', errorClass: 'http', httpStatus: 503, delayMs: 500 }, 3),
|
||||||
|
event('movement_complete', { next: 'done', outputPreview: 'private output' }, 4),
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
const result = await readMovementHistory(path);
|
||||||
|
expect(result.map(item => item.kind)).toEqual(['movement_start', 'llm_call_retry', 'movement_complete']);
|
||||||
|
expect(result[1]?.payload).toEqual({ attempt: 2, maxAttempts: 3, errorClass: 'http', httpStatus: 503, delayMs: 500 });
|
||||||
|
expect(JSON.stringify(result)).not.toContain('secret');
|
||||||
|
expect(JSON.stringify(result)).not.toContain('private output');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty history for a missing log', async () => {
|
||||||
|
await expect(readMovementHistory('/does/not/exist/events.jsonl')).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
108
src/bridge/local-tasks-movement-history-api.ts
Normal file
108
src/bridge/local-tasks-movement-history-api.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { type Application, type Request, type Response } from 'express';
|
||||||
|
import { open } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { parseEventLine } from '../progress/event-log.js';
|
||||||
|
import { logRoot } from '../spaces/runtime-paths.js';
|
||||||
|
import { canViewTask, resolveSpaceAccess } from './local-api-helpers.js';
|
||||||
|
import { type LocalTasksDeps } from './local-tasks-shared.js';
|
||||||
|
import { parseTaskId } from './validation.js';
|
||||||
|
|
||||||
|
const MAX_HISTORY_BYTES = 8 * 1024 * 1024;
|
||||||
|
const MAX_HISTORY_EVENTS = 2_000;
|
||||||
|
const ALLOWED_KINDS = new Set(['movement_start', 'movement_complete', 'llm_call_retry']);
|
||||||
|
|
||||||
|
export interface MovementHistoryEvent {
|
||||||
|
eventId: string;
|
||||||
|
ts: string;
|
||||||
|
seq: number;
|
||||||
|
line: number;
|
||||||
|
runId: string;
|
||||||
|
kind: 'movement_start' | 'movement_complete' | 'llm_call_retry';
|
||||||
|
movement: string | null;
|
||||||
|
iteration: number | null;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const readTailLines = async (path: string): Promise<string[]> => {
|
||||||
|
let file;
|
||||||
|
try {
|
||||||
|
file = await open(path, 'r');
|
||||||
|
const size = (await file.stat()).size;
|
||||||
|
const start = Math.max(0, size - MAX_HISTORY_BYTES);
|
||||||
|
const buffer = Buffer.alloc(size - start);
|
||||||
|
await file.read(buffer, 0, buffer.length, start);
|
||||||
|
const text = buffer.toString('utf8');
|
||||||
|
const lines = text.split('\n');
|
||||||
|
if (start > 0) lines.shift();
|
||||||
|
return lines;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
await file?.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const safePayload = (kind: string, payload: unknown): Record<string, unknown> => {
|
||||||
|
const source = payload && typeof payload === 'object' ? payload as Record<string, unknown> : {};
|
||||||
|
if (kind === 'movement_complete') {
|
||||||
|
return {
|
||||||
|
next: typeof source.next === 'string' ? source.next : null,
|
||||||
|
waitReason: typeof source.waitReason === 'string' ? source.waitReason : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (kind === 'llm_call_retry') {
|
||||||
|
return {
|
||||||
|
attempt: typeof source.attempt === 'number' ? source.attempt : null,
|
||||||
|
maxAttempts: typeof source.maxAttempts === 'number' ? source.maxAttempts : null,
|
||||||
|
errorClass: typeof source.errorClass === 'string' ? source.errorClass : null,
|
||||||
|
httpStatus: typeof source.httpStatus === 'number' ? source.httpStatus : null,
|
||||||
|
delayMs: typeof source.delayMs === 'number' ? source.delayMs : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readMovementHistory = async (path: string): Promise<MovementHistoryEvent[]> => {
|
||||||
|
const events: MovementHistoryEvent[] = [];
|
||||||
|
const lines = await readTailLines(path);
|
||||||
|
for (let line = 0; line < lines.length; line++) {
|
||||||
|
const raw = lines[line];
|
||||||
|
if (!raw?.trim()) continue;
|
||||||
|
const parsed = parseEventLine(raw);
|
||||||
|
if (parsed.kind !== 'ok' || !ALLOWED_KINDS.has(parsed.event.kind)) continue;
|
||||||
|
events.push({
|
||||||
|
eventId: parsed.event.eventId,
|
||||||
|
ts: parsed.event.ts,
|
||||||
|
seq: parsed.event.seq,
|
||||||
|
line,
|
||||||
|
runId: parsed.event.runId,
|
||||||
|
kind: parsed.event.kind as MovementHistoryEvent['kind'],
|
||||||
|
movement: parsed.event.movement ?? null,
|
||||||
|
iteration: parsed.event.iteration ?? null,
|
||||||
|
payload: safePayload(parsed.event.kind, parsed.event.payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return events.slice(-MAX_HISTORY_EVENTS);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const registerLocalTaskMovementHistoryRoutes = (app: Application, deps: LocalTasksDeps): void => {
|
||||||
|
app.get('/api/local/tasks/:taskId/movement-history', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const taskId = parseTaskId(req.params.taskId);
|
||||||
|
if (taskId === null) {
|
||||||
|
res.status(400).json({ error: 'Invalid task ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const viewer = req.user as Express.User | undefined;
|
||||||
|
const task = await deps.repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
|
||||||
|
if (!canViewTask(req, res, task, resolveSpaceAccess(deps.repo, task, viewer))) return;
|
||||||
|
const path = join(logRoot(task!), 'events.jsonl');
|
||||||
|
res.json({ events: await readMovementHistory(path) });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Movement history API error: ${err}`);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch movement history' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { mkdtempSync, rmSync } from 'fs';
|
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
@ -28,6 +28,7 @@ describe('createCoreServer subsystem wiring', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
delete process.env.MCP_ENCRYPTION_KEY;
|
delete process.env.MCP_ENCRYPTION_KEY;
|
||||||
|
delete process.env.AAO_CONFIG;
|
||||||
repo.close();
|
repo.close();
|
||||||
rmSync(tempDir, { recursive: true, force: true });
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
@ -57,6 +58,20 @@ describe('createCoreServer subsystem wiring', () => {
|
|||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('mounts the inactive console status stub when SSH is enabled but the key is absent', async () => {
|
||||||
|
const configPath = join(tempDir, 'config.yaml');
|
||||||
|
writeFileSync(configPath, 'ssh:\n enabled: true\n');
|
||||||
|
process.env.AAO_CONFIG = configPath;
|
||||||
|
delete process.env.MCP_ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
const { app, sshConsole } = createCoreServer({ repo, worktreeDir: join(tempDir, 'wt') });
|
||||||
|
expect(sshConsole).toBeNull();
|
||||||
|
|
||||||
|
const statusRes = await request(app).get('/api/local/tasks/some-task/console/status');
|
||||||
|
expect(statusRes.status).toBe(200);
|
||||||
|
expect(statusRes.body).toEqual({ active: false });
|
||||||
|
});
|
||||||
|
|
||||||
it('boots and serves /api/local/tasks regardless of MCP key', async () => {
|
it('boots and serves /api/local/tasks regardless of MCP key', async () => {
|
||||||
const { app } = createCoreServer({ repo, worktreeDir: join(tempDir, 'wt') });
|
const { app } = createCoreServer({ repo, worktreeDir: join(tempDir, 'wt') });
|
||||||
const res = await request(app).get('/api/local/tasks');
|
const res = await request(app).get('/api/local/tasks');
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { createBrowserSessionApi } from './browser-session-api.js';
|
|||||||
import { createSubtaskActivityRouter } from './subtask-activity-api.js';
|
import { createSubtaskActivityRouter } from './subtask-activity-api.js';
|
||||||
import { createDelegateRunsRouter } from './delegate-runs-api.js';
|
import { createDelegateRunsRouter } from './delegate-runs-api.js';
|
||||||
import { createUsageRouter } from './usage-api.js';
|
import { createUsageRouter } from './usage-api.js';
|
||||||
|
import { createLlmWorkersRouter } from './llm-workers-api.js';
|
||||||
import { SessionManager } from '../engine/browser-session.js';
|
import { SessionManager } from '../engine/browser-session.js';
|
||||||
import { setupNovncWebSocketProxy } from './novnc-proxy.js';
|
import { setupNovncWebSocketProxy } from './novnc-proxy.js';
|
||||||
import { mountNovncStatic, buildNovncSessionAuthorizer } from './novnc-mount.js';
|
import { mountNovncStatic, buildNovncSessionAuthorizer } from './novnc-mount.js';
|
||||||
@ -62,6 +63,7 @@ import { attachConsoleWs } from './console-ws-api.js';
|
|||||||
import type { GatewayMountHandle } from './gateway-mount.js';
|
import type { GatewayMountHandle } from './gateway-mount.js';
|
||||||
import { mountBridgeGateway } from './bridge-gateway-mount.js';
|
import { mountBridgeGateway } from './bridge-gateway-mount.js';
|
||||||
import { setupA2aSubsystem } from './a2a-subsystem.js';
|
import { setupA2aSubsystem } from './a2a-subsystem.js';
|
||||||
|
import { setupChatSubsystem } from './chat/chat-subsystem.js';
|
||||||
import { createServer as createHttpsServer } from 'https';
|
import { createServer as createHttpsServer } from 'https';
|
||||||
import { X509Certificate } from 'crypto';
|
import { X509Certificate } from 'crypto';
|
||||||
import { mergeServerConfig, resolveListenPort } from '../server/config.js';
|
import { mergeServerConfig, resolveListenPort } from '../server/config.js';
|
||||||
@ -282,6 +284,10 @@ export function createCoreServer(opts: CoreServerOptions): {
|
|||||||
: () => loadConfig().tools?.taskUploadMaxSizeMb ?? 50,
|
: () => loadConfig().tools?.taskUploadMaxSizeMb ?? 50,
|
||||||
// Package-request approvals install into the task's space overlay.
|
// Package-request approvals install into the task's space overlay.
|
||||||
getPythonPackagesConfig: () => loadConfig().pythonPackages,
|
getPythonPackagesConfig: () => loadConfig().pythonPackages,
|
||||||
|
// LLM 選択 Phase 1: タスク作成/PATCH の validateLlmSelection に渡す worker 一覧。
|
||||||
|
getLlmWorkers: opts.configManager
|
||||||
|
? () => opts.configManager!.getConfig().provider?.workers ?? []
|
||||||
|
: () => loadConfig().provider?.workers ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Local files API ---
|
// --- Local files API ---
|
||||||
@ -294,8 +300,20 @@ export function createCoreServer(opts: CoreServerOptions): {
|
|||||||
// 横断(全スペース)カレンダー: GET /api/calendar/cross
|
// 横断(全スペース)カレンダー: GET /api/calendar/cross
|
||||||
app.use('/api/calendar', createCrossCalendarApi({ repo, authActive }));
|
app.use('/api/calendar', createCrossCalendarApi({ repo, authActive }));
|
||||||
|
|
||||||
|
// LLM ワーカー選択 API(タスクへの pin 用): GET /api/llm/workers
|
||||||
|
// 実行ロール(auto/fast/quality)を持つワーカーのみ・秘密値非返却。
|
||||||
|
// requireAuth は auth-subsystem.ts 側(認証有効時のみ)で配線。
|
||||||
|
if (opts.configManager) {
|
||||||
|
app.use('/api/llm/workers', createLlmWorkersRouter(opts.configManager));
|
||||||
|
}
|
||||||
|
|
||||||
// --- A2A OAuth2 Authorization Server (see a2a-subsystem.ts; no-op unless a2a.enabled) ---
|
// --- A2A OAuth2 Authorization Server (see a2a-subsystem.ts; no-op unless a2a.enabled) ---
|
||||||
setupA2aSubsystem(app, { repo, authActive });
|
const { limiter: a2aLimiter } = setupA2aSubsystem(app, { repo, authActive });
|
||||||
|
|
||||||
|
// --- External chat connector (see chat/chat-subsystem.ts; no-op unless chat.enabled) ---
|
||||||
|
// Reuses the A2A limiter instance above so governance counters are shared
|
||||||
|
// per-grant across the JSON-RPC A2A path and the chat path.
|
||||||
|
setupChatSubsystem(app, { repo, authActive, a2aLimiter });
|
||||||
|
|
||||||
// --- Subtask files API (listing MUST come before wildcard) ---
|
// --- Subtask files API (listing MUST come before wildcard) ---
|
||||||
mountSubtaskFilesApi(app, repo);
|
mountSubtaskFilesApi(app, repo);
|
||||||
|
|||||||
@ -306,11 +306,16 @@ export function mountSetupApi(
|
|||||||
// when enabled. Expose the flag so the UI can hide the per-user A2A Delegations
|
// when enabled. Expose the flag so the UI can hide the per-user A2A Delegations
|
||||||
// settings section instead of showing a load error against the missing route.
|
// settings section instead of showing a load error against the missing route.
|
||||||
const a2aEnabled = ((configManager.getConfig() as { a2a?: { enabled?: boolean } })?.a2a?.enabled) === true;
|
const a2aEnabled = ((configManager.getConfig() as { a2a?: { enabled?: boolean } })?.a2a?.enabled) === true;
|
||||||
res.json({ needsSetup, authActive, port: listenPort, deployHint, tokenRequired, a2aEnabled });
|
// chat is off by default and mounts its /api/admin/chat/bindings route only
|
||||||
|
// when enabled (see chat-subsystem.ts). Expose the flag the same way a2a
|
||||||
|
// does so the UI can hide the admin Chat Connectors settings section
|
||||||
|
// instead of showing a load error against the missing route.
|
||||||
|
const chatEnabled = ((configManager.getConfig() as { chat?: { enabled?: boolean } })?.chat?.enabled) === true;
|
||||||
|
res.json({ needsSetup, authActive, port: listenPort, deployHint, tokenRequired, a2aEnabled, chatEnabled });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn(`[setup-api] status failed: ${String(e)}`);
|
logger.warn(`[setup-api] status failed: ${String(e)}`);
|
||||||
// Fail-closed: on error the UI treats a2a as disabled and hides the section.
|
// Fail-closed: on error the UI treats a2a/chat as disabled and hides the sections.
|
||||||
res.status(500).json({ needsSetup: false, authActive, a2aEnabled: false, error: 'status unavailable' });
|
res.status(500).json({ needsSetup: false, authActive, a2aEnabled: false, chatEnabled: false, error: 'status unavailable' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -191,6 +191,33 @@ describe('space-api calendar', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('reminder validation', () => {
|
||||||
|
it('rejects removing the start time while an existing reminder remains', async () => {
|
||||||
|
const created = await request(appAs(owner))
|
||||||
|
.post(`/api/local/spaces/${spaceId}/calendar/events`)
|
||||||
|
.send({ date: '2026-07-01', title: 'remind me', time: '09:00', reminder_minutes: 10 });
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
|
||||||
|
const patched = await request(appAs(owner))
|
||||||
|
.patch(`/api/local/spaces/${spaceId}/calendar/events/${created.body.id}`)
|
||||||
|
.send({ time: null });
|
||||||
|
expect(patched.status).toBe(400);
|
||||||
|
expect(patched.body.error).toContain('reminder_minutes requires a start time');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows removing a reminder while changing the event to all-day', async () => {
|
||||||
|
const created = await request(appAs(owner))
|
||||||
|
.post(`/api/local/spaces/${spaceId}/calendar/events`)
|
||||||
|
.send({ date: '2026-07-01', title: 'clear reminder', time: '09:00', reminder_minutes: 10 });
|
||||||
|
const patched = await request(appAs(owner))
|
||||||
|
.patch(`/api/local/spaces/${spaceId}/calendar/events/${created.body.id}`)
|
||||||
|
.send({ time: null, reminder_minutes: null });
|
||||||
|
expect(patched.status).toBe(200);
|
||||||
|
expect(patched.body.time).toBeNull();
|
||||||
|
expect(patched.body.reminderMinutes).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('multi-day events via HTTP', () => {
|
describe('multi-day events via HTTP', () => {
|
||||||
it('creates a span and surfaces it on every covered day in the month view', async () => {
|
it('creates a span and surfaces it on every covered day in the month view', async () => {
|
||||||
const created = await request(appAs(owner))
|
const created = await request(appAs(owner))
|
||||||
|
|||||||
@ -152,6 +152,9 @@ export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps):
|
|||||||
const multiDay = endDate != null && endDate > date;
|
const multiDay = endDate != null && endDate > date;
|
||||||
if (!multiDay && endTime < time) return res.status(400).json({ error: 'end_time must be on or after the start time' });
|
if (!multiDay && endTime < time) return res.status(400).json({ error: 'end_time must be on or after the start time' });
|
||||||
}
|
}
|
||||||
|
const reminderMinutes = req.body?.reminder_minutes == null || req.body.reminder_minutes === '' ? null : Number(req.body.reminder_minutes);
|
||||||
|
if (reminderMinutes != null && ![0, 5, 10, 30, 60].includes(reminderMinutes)) return res.status(400).json({ error: 'reminder_minutes must be one of 0, 5, 10, 30, 60' });
|
||||||
|
if (reminderMinutes != null && time == null) return res.status(400).json({ error: 'reminder_minutes requires a start time' });
|
||||||
const ownerId = viewer.id === 'local' ? null : viewer.id;
|
const ownerId = viewer.id === 'local' ? null : viewer.id;
|
||||||
const ev = await repo.createCalendarEvent({
|
const ev = await repo.createCalendarEvent({
|
||||||
spaceId: space.id,
|
spaceId: space.id,
|
||||||
@ -160,6 +163,7 @@ export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps):
|
|||||||
endDate,
|
endDate,
|
||||||
time,
|
time,
|
||||||
endTime,
|
endTime,
|
||||||
|
reminderMinutes,
|
||||||
title,
|
title,
|
||||||
description: req.body?.description != null ? String(req.body.description) : null,
|
description: req.body?.description != null ? String(req.body.description) : null,
|
||||||
createdBy: 'user',
|
createdBy: 'user',
|
||||||
@ -179,7 +183,7 @@ export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps):
|
|||||||
const eventId = Number(req.params.eventId);
|
const eventId = Number(req.params.eventId);
|
||||||
const existing = Number.isInteger(eventId) ? await repo.getCalendarEvent(eventId) : null;
|
const existing = Number.isInteger(eventId) ? await repo.getCalendarEvent(eventId) : null;
|
||||||
if (!existing || existing.spaceId !== space.id) return res.status(404).json({ error: 'event not found' });
|
if (!existing || existing.spaceId !== space.id) return res.status(404).json({ error: 'event not found' });
|
||||||
const patch: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null } = {};
|
const patch: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; reminderMinutes?: number | null; title?: string; description?: string | null } = {};
|
||||||
if (req.body?.date !== undefined) {
|
if (req.body?.date !== undefined) {
|
||||||
const d = String(req.body.date);
|
const d = String(req.body.date);
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return res.status(400).json({ error: 'date must be YYYY-MM-DD' });
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return res.status(400).json({ error: 'date must be YYYY-MM-DD' });
|
||||||
@ -211,12 +215,21 @@ export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps):
|
|||||||
if (et != null && !/^([01]\d|2[0-3]):[0-5]\d$/.test(et)) return res.status(400).json({ error: 'end_time must be HH:MM' });
|
if (et != null && !/^([01]\d|2[0-3]):[0-5]\d$/.test(et)) return res.status(400).json({ error: 'end_time must be HH:MM' });
|
||||||
patch.endTime = et;
|
patch.endTime = et;
|
||||||
}
|
}
|
||||||
|
if (req.body?.reminder_minutes !== undefined) {
|
||||||
|
const value = req.body.reminder_minutes === null || req.body.reminder_minutes === '' ? null : Number(req.body.reminder_minutes);
|
||||||
|
if (value != null && ![0, 5, 10, 30, 60].includes(value)) return res.status(400).json({ error: 'reminder_minutes must be one of 0, 5, 10, 30, 60' });
|
||||||
|
patch.reminderMinutes = value;
|
||||||
|
}
|
||||||
// 時刻の整合は「更新後の実効値」で検証する。time/date/end_date だけを変えて
|
// 時刻の整合は「更新後の実効値」で検証する。time/date/end_date だけを変えて
|
||||||
// end_time を省略したケース(開始を遅らせる・複数日を単日に戻す等)でも、
|
// end_time を省略したケース(開始を遅らせる・複数日を単日に戻す等)でも、
|
||||||
// 単日で終了<開始や開始時刻なしの終了時刻という不整合を確実に弾く。
|
// 単日で終了<開始や開始時刻なしの終了時刻という不整合を確実に弾く。
|
||||||
{
|
{
|
||||||
const effTime = patch.time !== undefined ? patch.time : existing.time;
|
const effTime = patch.time !== undefined ? patch.time : existing.time;
|
||||||
const effEndTime = patch.endTime !== undefined ? patch.endTime : existing.endTime;
|
const effEndTime = patch.endTime !== undefined ? patch.endTime : existing.endTime;
|
||||||
|
const effReminderMinutes = patch.reminderMinutes !== undefined ? patch.reminderMinutes : existing.reminderMinutes;
|
||||||
|
if (effReminderMinutes != null && effTime == null) {
|
||||||
|
return res.status(400).json({ error: 'reminder_minutes requires a start time' });
|
||||||
|
}
|
||||||
if (effEndTime != null) {
|
if (effEndTime != null) {
|
||||||
if (effTime == null) return res.status(400).json({ error: 'end_time requires a start time' });
|
if (effTime == null) return res.status(400).json({ error: 'end_time requires a start time' });
|
||||||
const effEnd = patch.endDate !== undefined ? patch.endDate : existing.endDate;
|
const effEnd = patch.endDate !== undefined ? patch.endDate : existing.endDate;
|
||||||
@ -251,4 +264,16 @@ export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps):
|
|||||||
await repo.deleteCalendarEvent(eventId);
|
await repo.deleteCalendarEvent(eventId);
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 表示中のクライアントが期限到来通知を一度だけ取得する。サーバー現在時刻で
|
||||||
|
// 判定し、クライアントが未来時刻を指定して先取りできないようにする。
|
||||||
|
router.post('/:id/calendar/reminders/claim', jsonParser, async (req, res) => {
|
||||||
|
const viewer = viewerOf(req, deps.authActive);
|
||||||
|
const space = await repo.getSpace(req.params.id, { viewer });
|
||||||
|
if (!space) return res.status(404).json({ error: 'not found' });
|
||||||
|
const current = new Date();
|
||||||
|
const now = `${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, '0')}-${String(current.getDate()).padStart(2, '0')} ${String(current.getHours()).padStart(2, '0')}:${String(current.getMinutes()).padStart(2, '0')}`;
|
||||||
|
const events = await repo.claimDueCalendarReminders(space.id, now);
|
||||||
|
res.json({ events });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -92,17 +92,19 @@ export function setupSshSubsystem(app: express.Application, deps: SshSubsystemDe
|
|||||||
let sshConsole: SshConsoleDeps | null = null;
|
let sshConsole: SshConsoleDeps | null = null;
|
||||||
|
|
||||||
const sshConfig = mergeSshConfig(loadConfig().ssh);
|
const sshConfig = mergeSshConfig(loadConfig().ssh);
|
||||||
if (!sshConfig.enabled) {
|
const sshAvailable = sshConfig.enabled && isKeyConfigured();
|
||||||
|
if (!sshAvailable) {
|
||||||
setSshSubsystem(null);
|
setSshSubsystem(null);
|
||||||
__setActiveSessionLookup(null);
|
__setActiveSessionLookup(null);
|
||||||
// SSH is off: the real console/status router (and the registry /
|
// SSH is off: the real console/status router (and the registry /
|
||||||
// resolveTask / resolveSshAccess it needs) never gets mounted below,
|
// resolveTask / resolveSshAccess it needs) never gets mounted below,
|
||||||
// but the UI still polls GET .../console/status every 5s regardless of
|
// but the UI still polls GET .../console/status every 5s regardless of
|
||||||
// SSH config. Without a stand-in, that poll 404s forever and spams the
|
// SSH config. Without a stand-in, that poll 404s forever and spams the
|
||||||
// DevTools console (issue #785). Mount a minimal stub that always
|
// DevTools console (issues #785, #813). Mount a minimal stub that always
|
||||||
// answers `{ active: false }` — scope note: the sibling
|
// answers `{ active: false }`.
|
||||||
// `!isKeyConfigured()` branch below has the SAME 404-polling problem
|
if (sshConfig.enabled) {
|
||||||
// but is deliberately left untouched here (separate issue).
|
logger.warn('[ssh] MCP_ENCRYPTION_KEY not configured — SSH features disabled');
|
||||||
|
}
|
||||||
app.use(
|
app.use(
|
||||||
'/api',
|
'/api',
|
||||||
createConsoleStatusStubRouter({
|
createConsoleStatusStubRouter({
|
||||||
@ -110,10 +112,6 @@ export function setupSshSubsystem(app: express.Application, deps: SshSubsystemDe
|
|||||||
requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(),
|
requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else if (!isKeyConfigured()) {
|
|
||||||
logger.warn('[ssh] MCP_ENCRYPTION_KEY not configured — SSH features disabled');
|
|
||||||
setSshSubsystem(null);
|
|
||||||
__setActiveSessionLookup(null);
|
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
bootstrapSystemDek(repo.getDb());
|
bootstrapSystemDek(repo.getDb());
|
||||||
|
|||||||
@ -540,6 +540,27 @@ export interface WebhooksConfig {
|
|||||||
publicBaseUrl?: string;
|
publicBaseUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External chat connector (issue #801, PR1 — Slack MVP). Master switch is
|
||||||
|
* `chat.enabled` (default false). Reuses the existing A2A execution/governance
|
||||||
|
* path in-process; there is no separate authz surface. snake_case in YAML:
|
||||||
|
* chat.{enabled,public_base_url,slack.signing_secret_max_age_sec}
|
||||||
|
*/
|
||||||
|
export interface ChatConfig {
|
||||||
|
/** Master switch for the chat connector subsystem. Default false. */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Absolute base URL used to build task-detail links in reply messages
|
||||||
|
* (e.g. "https://maestro.example.com"). Falls back to a relative path
|
||||||
|
* when unset. */
|
||||||
|
publicBaseUrl?: string;
|
||||||
|
slack?: {
|
||||||
|
/** Reject Slack Events API requests whose X-Slack-Request-Timestamp is
|
||||||
|
* older than this many seconds (replay protection). Default 300 (5 min),
|
||||||
|
* matching Slack's own recommendation. */
|
||||||
|
signingSecretMaxAgeSec?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
/**
|
/**
|
||||||
* Schema version. `2` = v2 layout (`llm.*` / `storage.*`). Missing or `1`
|
* Schema version. `2` = v2 layout (`llm.*` / `storage.*`). Missing or `1`
|
||||||
@ -616,6 +637,7 @@ export interface AppConfig {
|
|||||||
maxConcurrentResubscribe?: number;
|
maxConcurrentResubscribe?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
chat?: ChatConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_REFLECTION: ReflectionConfig = {
|
const DEFAULT_REFLECTION: ReflectionConfig = {
|
||||||
|
|||||||
30
src/db/migrate.chat-connector-events.test.ts
Normal file
30
src/db/migrate.chat-connector-events.test.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { runMigrations } from './migrate.js';
|
||||||
|
import { claimChatConnectorEvent } from './repositories/chat-connectors.js';
|
||||||
|
|
||||||
|
describe('chat connector processed-event migration', () => {
|
||||||
|
it('adds an idempotent event claim table to an existing bindings database', () => {
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE chat_connector_bindings (
|
||||||
|
id TEXT PRIMARY KEY, platform TEXT NOT NULL, external_workspace_id TEXT NOT NULL,
|
||||||
|
external_channel_id TEXT NOT NULL, space_id TEXT NOT NULL, a2a_client_id TEXT NOT NULL,
|
||||||
|
a2a_delegation_id TEXT NOT NULL, a2a_grant_id TEXT NOT NULL,
|
||||||
|
bot_credentials_enc BLOB NOT NULL, key_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active', created_by TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
runMigrations(db);
|
||||||
|
|
||||||
|
expect(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chat_connector_processed_events'").get()).toBeTruthy();
|
||||||
|
expect(claimChatConnectorEvent(db, 'Ev-upgrade-1')).toBe(true);
|
||||||
|
expect(claimChatConnectorEvent(db, 'Ev-upgrade-1')).toBe(false);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
221
src/db/migrate.llm-selection.test.ts
Normal file
221
src/db/migrate.llm-selection.test.ts
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { Repository } from './repository.js';
|
||||||
|
import { runMigrations } from './migrate.js';
|
||||||
|
|
||||||
|
describe('LLM selection Phase 1: jobs / local_tasks columns', () => {
|
||||||
|
let dir: string;
|
||||||
|
let r: Repository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'llm-selection-cols-'));
|
||||||
|
r = new Repository(join(dir, 'db.sqlite'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
r.close?.();
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jobs table has required_worker_id and reasoning_effort columns (nullable)', () => {
|
||||||
|
const cols = r.getDb()
|
||||||
|
.prepare("PRAGMA table_info(jobs)")
|
||||||
|
.all() as Array<{ name: string; notnull: number }>;
|
||||||
|
const requiredWorkerId = cols.find(c => c.name === 'required_worker_id');
|
||||||
|
const reasoningEffort = cols.find(c => c.name === 'reasoning_effort');
|
||||||
|
expect(requiredWorkerId).toBeTruthy();
|
||||||
|
expect(requiredWorkerId?.notnull).toBe(0);
|
||||||
|
expect(reasoningEffort).toBeTruthy();
|
||||||
|
expect(reasoningEffort?.notnull).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('local_tasks table has llm_worker_id and llm_effort columns (nullable)', () => {
|
||||||
|
const cols = r.getDb()
|
||||||
|
.prepare("PRAGMA table_info(local_tasks)")
|
||||||
|
.all() as Array<{ name: string; notnull: number }>;
|
||||||
|
const llmWorkerId = cols.find(c => c.name === 'llm_worker_id');
|
||||||
|
const llmEffort = cols.find(c => c.name === 'llm_effort');
|
||||||
|
expect(llmWorkerId).toBeTruthy();
|
||||||
|
expect(llmWorkerId?.notnull).toBe(0);
|
||||||
|
expect(llmEffort).toBeTruthy();
|
||||||
|
expect(llmEffort?.notnull).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runMigrations is idempotent when run twice on the same DB (ALTER does not throw)', () => {
|
||||||
|
// Repository constructor already ran migrations once via initSchema/migrate path.
|
||||||
|
// Running again must not throw ("duplicate column name").
|
||||||
|
expect(() => runMigrations(r.getDb())).not.toThrow();
|
||||||
|
expect(() => runMigrations(r.getDb())).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ALTER TABLE ADD COLUMN path works starting from a DB missing the 4 columns', () => {
|
||||||
|
// Simulate an old DB: create fresh jobs/local_tasks tables WITHOUT the new
|
||||||
|
// columns (pre-Phase-1 shape), then run migrations and assert they appear.
|
||||||
|
const oldDir = mkdtempSync(join(tmpdir(), 'llm-selection-old-'));
|
||||||
|
try {
|
||||||
|
const db = new Database(join(oldDir, 'old.sqlite'));
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
repo TEXT NOT NULL,
|
||||||
|
issue_number INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
piece_name TEXT NOT NULL DEFAULT 'general',
|
||||||
|
required_profile TEXT NOT NULL DEFAULT 'auto',
|
||||||
|
task_class TEXT NOT NULL DEFAULT 'auto',
|
||||||
|
instruction TEXT NOT NULL DEFAULT '',
|
||||||
|
attempt INTEGER NOT NULL DEFAULT 1,
|
||||||
|
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||||
|
ask_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
subtask_depth INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE local_tasks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
piece_name TEXT NOT NULL DEFAULT 'general',
|
||||||
|
profile TEXT NOT NULL DEFAULT 'auto',
|
||||||
|
output_format TEXT NOT NULL DEFAULT 'markdown',
|
||||||
|
ask_policy TEXT NOT NULL DEFAULT 'low',
|
||||||
|
priority TEXT NOT NULL DEFAULT 'medium',
|
||||||
|
state TEXT NOT NULL DEFAULT 'open',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const beforeCols = db.prepare("PRAGMA table_info(jobs)").all() as Array<{ name: string }>;
|
||||||
|
expect(beforeCols.some(c => c.name === 'required_worker_id')).toBe(false);
|
||||||
|
|
||||||
|
runMigrations(db);
|
||||||
|
// Run twice to prove idempotency from the old-schema starting point too.
|
||||||
|
runMigrations(db);
|
||||||
|
|
||||||
|
const afterJobsCols = db.prepare("PRAGMA table_info(jobs)").all() as Array<{ name: string }>;
|
||||||
|
expect(afterJobsCols.some(c => c.name === 'required_worker_id')).toBe(true);
|
||||||
|
expect(afterJobsCols.some(c => c.name === 'reasoning_effort')).toBe(true);
|
||||||
|
|
||||||
|
const afterTasksCols = db.prepare("PRAGMA table_info(local_tasks)").all() as Array<{ name: string }>;
|
||||||
|
expect(afterTasksCols.some(c => c.name === 'llm_worker_id')).toBe(true);
|
||||||
|
expect(afterTasksCols.some(c => c.name === 'llm_effort')).toBe(true);
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
} finally {
|
||||||
|
rmSync(oldDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LLM selection Phase 1: bare `new Repository()` compat-init path (no runMigrations)', () => {
|
||||||
|
// Repository's constructor calls initSchema() only (src/db/repository.ts:58),
|
||||||
|
// NOT runMigrations(). initSchema is a separate compat-upgrade path
|
||||||
|
// (src/db/repositories/schema.ts) that must independently ALTER in the 4
|
||||||
|
// new columns, or any code path that does `new Repository(dbPath)` against
|
||||||
|
// a pre-Phase-1 DB (tests, tools, integration paths that skip
|
||||||
|
// runMigrations) will hit "no column required_worker_id" in createJob.
|
||||||
|
it('adds the 4 columns and allows createJob(requiredWorkerId=...) on a pre-Phase-1 DB opened via bare Repository construction', async () => {
|
||||||
|
const oldDir = mkdtempSync(join(tmpdir(), 'llm-selection-bare-'));
|
||||||
|
try {
|
||||||
|
const dbPath = join(oldDir, 'old.sqlite');
|
||||||
|
const raw = new Database(dbPath);
|
||||||
|
raw.exec(`
|
||||||
|
CREATE TABLE jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
repo TEXT NOT NULL,
|
||||||
|
issue_number INTEGER NOT NULL,
|
||||||
|
pr_number INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued'
|
||||||
|
CHECK (status IN ('queued','dispatching','running','succeeded','failed','retry','cancelled','waiting_human','waiting_subtasks')),
|
||||||
|
piece_name TEXT NOT NULL DEFAULT 'general',
|
||||||
|
required_profile TEXT NOT NULL DEFAULT 'auto',
|
||||||
|
task_class TEXT NOT NULL DEFAULT 'auto',
|
||||||
|
current_movement TEXT,
|
||||||
|
instruction TEXT NOT NULL DEFAULT '',
|
||||||
|
branch_name TEXT,
|
||||||
|
worktree_path TEXT,
|
||||||
|
attempt INTEGER NOT NULL DEFAULT 1,
|
||||||
|
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||||
|
next_retry_at TEXT,
|
||||||
|
error_summary TEXT,
|
||||||
|
resume_movement TEXT,
|
||||||
|
ask_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
worker_id TEXT,
|
||||||
|
parent_job_id TEXT,
|
||||||
|
subtask_depth INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE local_tasks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
piece_name TEXT NOT NULL DEFAULT 'general',
|
||||||
|
profile TEXT NOT NULL DEFAULT 'auto',
|
||||||
|
output_format TEXT NOT NULL DEFAULT 'markdown',
|
||||||
|
ask_policy TEXT NOT NULL DEFAULT 'low',
|
||||||
|
priority TEXT NOT NULL DEFAULT 'medium',
|
||||||
|
state TEXT NOT NULL DEFAULT 'open',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const beforeCols = raw.prepare("PRAGMA table_info(jobs)").all() as Array<{ name: string }>;
|
||||||
|
expect(beforeCols.some(c => c.name === 'required_worker_id')).toBe(false);
|
||||||
|
raw.close();
|
||||||
|
|
||||||
|
// Bare construction — deliberately does NOT call runMigrations.
|
||||||
|
const repo = new Repository(dbPath);
|
||||||
|
try {
|
||||||
|
const jobsCols = repo.getDb().prepare("PRAGMA table_info(jobs)").all() as Array<{ name: string }>;
|
||||||
|
expect(jobsCols.some(c => c.name === 'required_worker_id')).toBe(true);
|
||||||
|
expect(jobsCols.some(c => c.name === 'reasoning_effort')).toBe(true);
|
||||||
|
|
||||||
|
const taskCols = repo.getDb().prepare("PRAGMA table_info(local_tasks)").all() as Array<{ name: string }>;
|
||||||
|
expect(taskCols.some(c => c.name === 'llm_worker_id')).toBe(true);
|
||||||
|
expect(taskCols.some(c => c.name === 'llm_effort')).toBe(true);
|
||||||
|
|
||||||
|
// End-to-end: createJob with a pin must not throw a SQLite
|
||||||
|
// "no column required_worker_id" error on this bare-init DB.
|
||||||
|
const job = await repo.createJob({
|
||||||
|
repo: 'local/task-1',
|
||||||
|
issueNumber: 1,
|
||||||
|
instruction: 'go',
|
||||||
|
pieceName: 'general',
|
||||||
|
requiredWorkerId: 'worker-gpu-1',
|
||||||
|
reasoningEffort: 'high',
|
||||||
|
});
|
||||||
|
expect(job.requiredWorkerId).toBe('worker-gpu-1');
|
||||||
|
expect(job.reasoningEffort).toBe('high');
|
||||||
|
} finally {
|
||||||
|
repo.close?.();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rmSync(oldDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -222,6 +222,22 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
db.exec("ALTER TABLE local_tasks ADD COLUMN title_source TEXT NOT NULL DEFAULT 'auto'");
|
db.exec("ALTER TABLE local_tasks ADD COLUMN title_source TEXT NOT NULL DEFAULT 'auto'");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// LLM 選択 Phase 1: per-job worker/effort pin + per-task スティッキー選択。
|
||||||
|
// NULL = 従来の profile ルーティング(後方互換)。
|
||||||
|
// spec: docs/superpowers/specs/2026-07-09-llm-selection-effort-design.md
|
||||||
|
addColumnIfMissing(db, 'jobs', 'required_worker_id', () => {
|
||||||
|
db.exec('ALTER TABLE jobs ADD COLUMN required_worker_id TEXT');
|
||||||
|
});
|
||||||
|
addColumnIfMissing(db, 'jobs', 'reasoning_effort', () => {
|
||||||
|
db.exec('ALTER TABLE jobs ADD COLUMN reasoning_effort TEXT');
|
||||||
|
});
|
||||||
|
addColumnIfMissing(db, 'local_tasks', 'llm_worker_id', () => {
|
||||||
|
db.exec('ALTER TABLE local_tasks ADD COLUMN llm_worker_id TEXT');
|
||||||
|
});
|
||||||
|
addColumnIfMissing(db, 'local_tasks', 'llm_effort', () => {
|
||||||
|
db.exec('ALTER TABLE local_tasks ADD COLUMN llm_effort TEXT');
|
||||||
|
});
|
||||||
|
|
||||||
migrateMcpTables(db);
|
migrateMcpTables(db);
|
||||||
migrateSshTables(db);
|
migrateSshTables(db);
|
||||||
migrateDashboardWidgets(db);
|
migrateDashboardWidgets(db);
|
||||||
@ -233,6 +249,7 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
migrateSpaceUserPrefs(db);
|
migrateSpaceUserPrefs(db);
|
||||||
migrateSpaceMembers(db);
|
migrateSpaceMembers(db);
|
||||||
migrateCalendarEvents(db);
|
migrateCalendarEvents(db);
|
||||||
|
migrateAgentReminders(db);
|
||||||
migrateSpaceInvites(db);
|
migrateSpaceInvites(db);
|
||||||
migrateAppShareLinks(db);
|
migrateAppShareLinks(db);
|
||||||
migratePrivatizeSpaceRows(db);
|
migratePrivatizeSpaceRows(db);
|
||||||
@ -245,6 +262,7 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
migrateBackfillTaskCommentIndex(db);
|
migrateBackfillTaskCommentIndex(db);
|
||||||
migrateA2aTaskDelegationColumns(db);
|
migrateA2aTaskDelegationColumns(db);
|
||||||
migrateSpaceWebhooksTables(db);
|
migrateSpaceWebhooksTables(db);
|
||||||
|
migrateChatConnectorBindings(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -523,6 +541,23 @@ function migrateCalendarEvents(db: Database.Database): void {
|
|||||||
addColumnIfMissing(db, 'calendar_events', 'end_time', () => {
|
addColumnIfMissing(db, 'calendar_events', 'end_time', () => {
|
||||||
db.exec("ALTER TABLE calendar_events ADD COLUMN end_time TEXT");
|
db.exec("ALTER TABLE calendar_events ADD COLUMN end_time TEXT");
|
||||||
});
|
});
|
||||||
|
addColumnIfMissing(db, 'calendar_events', 'reminder_minutes', () => {
|
||||||
|
db.exec("ALTER TABLE calendar_events ADD COLUMN reminder_minutes INTEGER");
|
||||||
|
});
|
||||||
|
addColumnIfMissing(db, 'calendar_events', 'reminder_delivered_at', () => {
|
||||||
|
db.exec("ALTER TABLE calendar_events ADD COLUMN reminder_delivered_at TEXT");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateAgentReminders(db: Database.Database): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_reminders (
|
||||||
|
id TEXT PRIMARY KEY, owner_id TEXT, space_id TEXT,
|
||||||
|
title TEXT, body TEXT NOT NULL, image_url TEXT, due_at TEXT NOT NULL,
|
||||||
|
delivered_at TEXT, cancelled_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_reminders_due ON agent_reminders (due_at) WHERE delivered_at IS NULL AND cancelled_at IS NULL;
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1421,3 +1456,38 @@ function migrateA2aTaskDelegationColumns(db: Database.Database): void {
|
|||||||
db.exec('ALTER TABLE a2a_tasks ADD COLUMN acting_user_id TEXT');
|
db.exec('ALTER TABLE a2a_tasks ADD COLUMN acting_user_id TEXT');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* chat_connector_bindings (issue #801, PR1 — Slack MVP).
|
||||||
|
* dual-path: schema.sql has the same CREATE TABLE for fresh DBs.
|
||||||
|
*/
|
||||||
|
function migrateChatConnectorBindings(db: Database.Database): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_connector_bindings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
platform TEXT NOT NULL DEFAULT 'slack',
|
||||||
|
external_workspace_id TEXT NOT NULL,
|
||||||
|
external_channel_id TEXT NOT NULL,
|
||||||
|
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||||
|
a2a_client_id TEXT NOT NULL,
|
||||||
|
a2a_delegation_id TEXT NOT NULL,
|
||||||
|
a2a_grant_id TEXT NOT NULL,
|
||||||
|
bot_credentials_enc BLOB NOT NULL,
|
||||||
|
key_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_by TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_bindings_channel
|
||||||
|
ON chat_connector_bindings (platform, external_workspace_id, external_channel_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_bindings_space ON chat_connector_bindings (space_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_bindings_delegation ON chat_connector_bindings (a2a_delegation_id);
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_connector_processed_events (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
received_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_processed_events_received_at
|
||||||
|
ON chat_connector_processed_events (received_at);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|||||||
@ -24,6 +24,8 @@ export interface CalendarEvent {
|
|||||||
endDate: string | null; // 終了日 YYYY-MM-DD。null = date と同じ(単日)
|
endDate: string | null; // 終了日 YYYY-MM-DD。null = date と同じ(単日)
|
||||||
time: string | null; // 開始 HH:MM。null = 終日
|
time: string | null; // 開始 HH:MM。null = 終日
|
||||||
endTime: string | null; // 終了 HH:MM。null = 終了時刻なし(time が null なら常に null)
|
endTime: string | null; // 終了 HH:MM。null = 終了時刻なし(time が null なら常に null)
|
||||||
|
reminderMinutes: number | null;
|
||||||
|
reminderDeliveredAt: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
createdBy: 'user' | 'agent';
|
createdBy: 'user' | 'agent';
|
||||||
@ -41,6 +43,8 @@ export interface CalendarEventRow {
|
|||||||
end_date: string | null;
|
end_date: string | null;
|
||||||
time: string | null;
|
time: string | null;
|
||||||
end_time: string | null;
|
end_time: string | null;
|
||||||
|
reminder_minutes: number | null;
|
||||||
|
reminder_delivered_at: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
created_by: string;
|
created_by: string;
|
||||||
@ -80,6 +84,8 @@ export function rowToCalendarEvent(row: CalendarEventRow): CalendarEvent {
|
|||||||
endDate: row.end_date ?? null,
|
endDate: row.end_date ?? null,
|
||||||
time: row.time ?? null,
|
time: row.time ?? null,
|
||||||
endTime: row.end_time ?? null,
|
endTime: row.end_time ?? null,
|
||||||
|
reminderMinutes: row.reminder_minutes ?? null,
|
||||||
|
reminderDeliveredAt: row.reminder_delivered_at ?? null,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
description: row.description ?? null,
|
description: row.description ?? null,
|
||||||
createdBy: row.created_by === 'agent' ? 'agent' : 'user',
|
createdBy: row.created_by === 'agent' ? 'agent' : 'user',
|
||||||
@ -145,6 +151,7 @@ export interface CrossSpaceCalendarMonth {
|
|||||||
endDate?: string | null;
|
endDate?: string | null;
|
||||||
time?: string | null;
|
time?: string | null;
|
||||||
endTime?: string | null;
|
endTime?: string | null;
|
||||||
|
reminderMinutes?: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
createdBy?: 'user' | 'agent';
|
createdBy?: 'user' | 'agent';
|
||||||
@ -157,8 +164,8 @@ export interface CrossSpaceCalendarMonth {
|
|||||||
const endTime = time ? (params.endTime ?? null) : null;
|
const endTime = time ? (params.endTime ?? null) : null;
|
||||||
const result = db
|
const result = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO calendar_events (space_id, owner_id, date, end_date, time, end_time, title, description, created_by, source_task_id)
|
`INSERT INTO calendar_events (space_id, owner_id, date, end_date, time, end_time, reminder_minutes, title, description, created_by, source_task_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
params.spaceId,
|
params.spaceId,
|
||||||
@ -167,6 +174,7 @@ export interface CrossSpaceCalendarMonth {
|
|||||||
endDate,
|
endDate,
|
||||||
time,
|
time,
|
||||||
endTime,
|
endTime,
|
||||||
|
params.reminderMinutes ?? null,
|
||||||
params.title,
|
params.title,
|
||||||
params.description ?? null,
|
params.description ?? null,
|
||||||
params.createdBy ?? 'user',
|
params.createdBy ?? 'user',
|
||||||
@ -210,7 +218,7 @@ export interface CrossSpaceCalendarMonth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function updateCalendarEvent(db: Database.Database, eventId: number, fields: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null }): Promise<CalendarEvent | null> {
|
export async function updateCalendarEvent(db: Database.Database, eventId: number, fields: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; reminderMinutes?: number | null; title?: string; description?: string | null }): Promise<CalendarEvent | null> {
|
||||||
const sets: string[] = [];
|
const sets: string[] = [];
|
||||||
const args: unknown[] = [];
|
const args: unknown[] = [];
|
||||||
if (fields.date !== undefined) {
|
if (fields.date !== undefined) {
|
||||||
@ -244,6 +252,13 @@ export interface CrossSpaceCalendarMonth {
|
|||||||
sets.push('description = ?');
|
sets.push('description = ?');
|
||||||
args.push(fields.description);
|
args.push(fields.description);
|
||||||
}
|
}
|
||||||
|
if (fields.reminderMinutes !== undefined) {
|
||||||
|
sets.push('reminder_minutes = ?');
|
||||||
|
args.push(fields.reminderMinutes);
|
||||||
|
}
|
||||||
|
if (fields.date !== undefined || fields.time !== undefined || fields.reminderMinutes !== undefined) {
|
||||||
|
sets.push('reminder_delivered_at = NULL');
|
||||||
|
}
|
||||||
if (sets.length === 0) return getCalendarEvent(db, eventId);
|
if (sets.length === 0) return getCalendarEvent(db, eventId);
|
||||||
sets.push(`updated_at = datetime('now')`);
|
sets.push(`updated_at = datetime('now')`);
|
||||||
args.push(eventId);
|
args.push(eventId);
|
||||||
@ -256,6 +271,31 @@ export interface CrossSpaceCalendarMonth {
|
|||||||
db.prepare(`DELETE FROM calendar_events WHERE id = ?`).run(eventId);
|
db.prepare(`DELETE FROM calendar_events WHERE id = ?`).run(eventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指定したローカル時刻までに到来した予定を一度だけ claim する。
|
||||||
|
* UPDATE の条件にも未配信を入れるため、複数タブ/プロセスが同時に呼んでも重複しない。
|
||||||
|
*/
|
||||||
|
export async function claimDueCalendarReminders(db: Database.Database, spaceId: string, nowLocal: string): Promise<CalendarEvent[]> {
|
||||||
|
const claim = db.transaction(() => {
|
||||||
|
const candidates = db.prepare(
|
||||||
|
`SELECT * FROM calendar_events
|
||||||
|
WHERE space_id = ? AND time IS NOT NULL AND reminder_minutes IS NOT NULL
|
||||||
|
AND reminder_delivered_at IS NULL
|
||||||
|
AND datetime(date || ' ' || time, '-' || reminder_minutes || ' minutes') <= datetime(?)`
|
||||||
|
).all(spaceId, nowLocal) as CalendarEventRow[];
|
||||||
|
const updated: CalendarEvent[] = [];
|
||||||
|
const mark = db.prepare(
|
||||||
|
`UPDATE calendar_events SET reminder_delivered_at = datetime('now'), updated_at = datetime('now')
|
||||||
|
WHERE id = ? AND reminder_delivered_at IS NULL`
|
||||||
|
);
|
||||||
|
for (const row of candidates) {
|
||||||
|
if (mark.run(row.id).changes === 1) updated.push(rowToCalendarEvent({ ...row, reminder_delivered_at: new Date().toISOString() }));
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
return claim();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 月ビューの日別カウント。`days` は date → {taskCount, eventCount}。
|
* 月ビューの日別カウント。`days` は date → {taskCount, eventCount}。
|
||||||
|
|||||||
140
src/db/repositories/chat-connectors.test.ts
Normal file
140
src/db/repositories/chat-connectors.test.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { Repository } from '../repository.js';
|
||||||
|
|
||||||
|
describe('chat_connector_bindings repository', () => {
|
||||||
|
let tempDir = '';
|
||||||
|
let repo: Repository;
|
||||||
|
let spaceId = '';
|
||||||
|
let clientId = '';
|
||||||
|
let delegationId = '';
|
||||||
|
let grantId = '';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), 'maestro-chat-connectors-'));
|
||||||
|
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||||
|
const owner = repo.createUser({ email: 'owner@example.com', name: 'Owner', role: 'user', status: 'active' });
|
||||||
|
const space = await repo.createSpace({ kind: 'case', title: 'Case', ownerId: owner.id, visibility: 'private' });
|
||||||
|
spaceId = space.id;
|
||||||
|
clientId = `a2a_${randomUUID()}`;
|
||||||
|
repo.createA2aClient({
|
||||||
|
clientId, name: 'Test App', redirectUris: ['https://example.com/cb'],
|
||||||
|
secretHash: null, tokenEndpointAuthMethod: 'none', agentCardUrl: null, issuer: null,
|
||||||
|
keyFingerprint: null, status: 'active', createdBy: null,
|
||||||
|
});
|
||||||
|
delegationId = randomUUID();
|
||||||
|
grantId = randomUUID();
|
||||||
|
repo.createA2aDelegation({
|
||||||
|
id: delegationId, userId: owner.id, clientId, grantId,
|
||||||
|
grantedSpaceIds: [spaceId], grantedSkills: ['chat'],
|
||||||
|
audience: null, expiresAt: null, revokedAt: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
repo.close();
|
||||||
|
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
|
||||||
|
});
|
||||||
|
|
||||||
|
const credsEnc = () => Buffer.from('fake-ciphertext-not-real-crypto');
|
||||||
|
|
||||||
|
it('creates a binding and returns the full record (credentials as an opaque Buffer)', () => {
|
||||||
|
const created = repo.createChatConnectorBinding({
|
||||||
|
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1',
|
||||||
|
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||||
|
botCredentialsEnc: credsEnc(), createdBy: 'admin1',
|
||||||
|
});
|
||||||
|
expect(created.id).toBeTruthy();
|
||||||
|
expect(created.status).toBe('active');
|
||||||
|
expect(created.botCredentialsEnc).toBeInstanceOf(Buffer);
|
||||||
|
expect(repo.getChatConnectorBindingById(created.id)).toEqual(created);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findActiveChatConnectorBinding resolves by (platform, team, channel)', () => {
|
||||||
|
const created = repo.createChatConnectorBinding({
|
||||||
|
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1',
|
||||||
|
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||||
|
botCredentialsEnc: credsEnc(),
|
||||||
|
});
|
||||||
|
const found = repo.findActiveChatConnectorBinding('slack', 'T1', 'C1');
|
||||||
|
expect(found?.id).toBe(created.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findActiveChatConnectorBinding returns null for an unknown (team, channel)', () => {
|
||||||
|
expect(repo.findActiveChatConnectorBinding('slack', 'T-unknown', 'C-unknown')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findActiveChatConnectorBinding returns null once the binding is disabled (fail-closed)', () => {
|
||||||
|
const created = repo.createChatConnectorBinding({
|
||||||
|
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1',
|
||||||
|
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||||
|
botCredentialsEnc: credsEnc(),
|
||||||
|
});
|
||||||
|
repo.updateChatConnectorBinding(created.id, { status: 'disabled' });
|
||||||
|
expect(repo.findActiveChatConnectorBinding('slack', 'T1', 'C1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces a unique (platform, team, channel) constraint', () => {
|
||||||
|
repo.createChatConnectorBinding({
|
||||||
|
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1',
|
||||||
|
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||||
|
botCredentialsEnc: credsEnc(),
|
||||||
|
});
|
||||||
|
expect(() => repo.createChatConnectorBinding({
|
||||||
|
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1',
|
||||||
|
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||||
|
botCredentialsEnc: credsEnc(),
|
||||||
|
})).toThrow(/UNIQUE constraint failed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateChatConnectorBinding re-encrypts credentials and bumps updated_at', () => {
|
||||||
|
const created = repo.createChatConnectorBinding({
|
||||||
|
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1',
|
||||||
|
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||||
|
botCredentialsEnc: credsEnc(),
|
||||||
|
});
|
||||||
|
const newCreds = Buffer.from('rotated-ciphertext');
|
||||||
|
const updated = repo.updateChatConnectorBinding(created.id, { botCredentialsEnc: newCreds });
|
||||||
|
expect(updated?.botCredentialsEnc.equals(newCreds)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listChatConnectorBindingsForDelegation returns bindings backed by a given delegation', () => {
|
||||||
|
const created = repo.createChatConnectorBinding({
|
||||||
|
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1',
|
||||||
|
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||||
|
botCredentialsEnc: credsEnc(),
|
||||||
|
});
|
||||||
|
const list = repo.listChatConnectorBindingsForDelegation(delegationId);
|
||||||
|
expect(list.map(b => b.id)).toEqual([created.id]);
|
||||||
|
expect(repo.listChatConnectorBindingsForDelegation('nonexistent')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteChatConnectorBinding removes the row', () => {
|
||||||
|
const created = repo.createChatConnectorBinding({
|
||||||
|
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1',
|
||||||
|
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||||
|
botCredentialsEnc: credsEnc(),
|
||||||
|
});
|
||||||
|
repo.deleteChatConnectorBinding(created.id);
|
||||||
|
expect(repo.getChatConnectorBindingById(created.id)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listChatConnectorBindings lists all bindings regardless of status', () => {
|
||||||
|
const a = repo.createChatConnectorBinding({
|
||||||
|
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1',
|
||||||
|
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||||
|
botCredentialsEnc: credsEnc(),
|
||||||
|
});
|
||||||
|
repo.updateChatConnectorBinding(a.id, { status: 'disabled' });
|
||||||
|
const b = repo.createChatConnectorBinding({
|
||||||
|
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C2',
|
||||||
|
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||||
|
botCredentialsEnc: credsEnc(),
|
||||||
|
});
|
||||||
|
const all = repo.listChatConnectorBindings();
|
||||||
|
expect(all.map(x => x.id).sort()).toEqual([a.id, b.id].sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
206
src/db/repositories/chat-connectors.ts
Normal file
206
src/db/repositories/chat-connectors.ts
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
// chat_connector_bindings repository domain (issue #801, PR1 — Slack MVP).
|
||||||
|
// Follows the same sub-repository + thin-delegation-on-Repository pattern as
|
||||||
|
// webhooks.ts / space_webhooks.
|
||||||
|
//
|
||||||
|
// SECURITY: bot_credentials_enc is an AES-256-GCM envelope-encrypted BLOB
|
||||||
|
// (src/mcp/crypto.ts, same scheme as space_webhooks.url_enc). This module
|
||||||
|
// never decrypts — callers (chat-connector-service.ts) decrypt just-in-time
|
||||||
|
// and never log or return the plaintext. rowToChatConnectorBinding() keeps
|
||||||
|
// bot_credentials_enc as an opaque Buffer; the admin API layer
|
||||||
|
// (chat-connector-bindings-api.ts) must never place it, or a decrypted
|
||||||
|
// credential, into a JSON response.
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
export type ChatConnectorPlatform = 'slack';
|
||||||
|
export type ChatConnectorBindingStatus = 'active' | 'disabled';
|
||||||
|
|
||||||
|
export interface ChatConnectorBindingRecord {
|
||||||
|
id: string;
|
||||||
|
platform: ChatConnectorPlatform;
|
||||||
|
externalWorkspaceId: string;
|
||||||
|
externalChannelId: string;
|
||||||
|
spaceId: string;
|
||||||
|
a2aClientId: string;
|
||||||
|
a2aDelegationId: string;
|
||||||
|
a2aGrantId: string;
|
||||||
|
botCredentialsEnc: Buffer;
|
||||||
|
keyVersion: number;
|
||||||
|
status: ChatConnectorBindingStatus;
|
||||||
|
createdBy: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateChatConnectorBindingInput {
|
||||||
|
platform: ChatConnectorPlatform;
|
||||||
|
externalWorkspaceId: string;
|
||||||
|
externalChannelId: string;
|
||||||
|
spaceId: string;
|
||||||
|
a2aClientId: string;
|
||||||
|
a2aDelegationId: string;
|
||||||
|
a2aGrantId: string;
|
||||||
|
botCredentialsEnc: Buffer;
|
||||||
|
keyVersion?: number;
|
||||||
|
createdBy?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateChatConnectorBindingInput {
|
||||||
|
/** Present only when the caller re-entered credentials (write-only). */
|
||||||
|
botCredentialsEnc?: Buffer;
|
||||||
|
keyVersion?: number;
|
||||||
|
status?: ChatConnectorBindingStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatConnectorBindingRow {
|
||||||
|
id: string;
|
||||||
|
platform: string;
|
||||||
|
external_workspace_id: string;
|
||||||
|
external_channel_id: string;
|
||||||
|
space_id: string;
|
||||||
|
a2a_client_id: string;
|
||||||
|
a2a_delegation_id: string;
|
||||||
|
a2a_grant_id: string;
|
||||||
|
bot_credentials_enc: Buffer;
|
||||||
|
key_version: number;
|
||||||
|
status: string;
|
||||||
|
created_by: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToBinding(row: ChatConnectorBindingRow): ChatConnectorBindingRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
platform: row.platform as ChatConnectorPlatform,
|
||||||
|
externalWorkspaceId: row.external_workspace_id,
|
||||||
|
externalChannelId: row.external_channel_id,
|
||||||
|
spaceId: row.space_id,
|
||||||
|
a2aClientId: row.a2a_client_id,
|
||||||
|
a2aDelegationId: row.a2a_delegation_id,
|
||||||
|
a2aGrantId: row.a2a_grant_id,
|
||||||
|
botCredentialsEnc: row.bot_credentials_enc,
|
||||||
|
keyVersion: row.key_version,
|
||||||
|
status: row.status as ChatConnectorBindingStatus,
|
||||||
|
createdBy: row.created_by,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELECT_COLUMNS = `id, platform, external_workspace_id, external_channel_id, space_id,
|
||||||
|
a2a_client_id, a2a_delegation_id, a2a_grant_id, bot_credentials_enc, key_version,
|
||||||
|
status, created_by, created_at, updated_at`;
|
||||||
|
|
||||||
|
export function createChatConnectorBinding(
|
||||||
|
db: Database.Database,
|
||||||
|
input: CreateChatConnectorBindingInput,
|
||||||
|
): ChatConnectorBindingRecord {
|
||||||
|
const id = randomUUID();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO chat_connector_bindings
|
||||||
|
(id, platform, external_workspace_id, external_channel_id, space_id,
|
||||||
|
a2a_client_id, a2a_delegation_id, a2a_grant_id, bot_credentials_enc, key_version, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(
|
||||||
|
id,
|
||||||
|
input.platform,
|
||||||
|
input.externalWorkspaceId,
|
||||||
|
input.externalChannelId,
|
||||||
|
input.spaceId,
|
||||||
|
input.a2aClientId,
|
||||||
|
input.a2aDelegationId,
|
||||||
|
input.a2aGrantId,
|
||||||
|
input.botCredentialsEnc,
|
||||||
|
input.keyVersion ?? 1,
|
||||||
|
input.createdBy ?? null,
|
||||||
|
);
|
||||||
|
return getChatConnectorBindingById(db, id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listChatConnectorBindings(db: Database.Database): ChatConnectorBindingRecord[] {
|
||||||
|
const rows = db
|
||||||
|
.prepare(`SELECT ${SELECT_COLUMNS} FROM chat_connector_bindings ORDER BY created_at ASC`)
|
||||||
|
.all() as ChatConnectorBindingRow[];
|
||||||
|
return rows.map(rowToBinding);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChatConnectorBindingById(db: Database.Database, id: string): ChatConnectorBindingRecord | null {
|
||||||
|
const row = db
|
||||||
|
.prepare(`SELECT ${SELECT_COLUMNS} FROM chat_connector_bindings WHERE id = ?`)
|
||||||
|
.get(id) as ChatConnectorBindingRow | undefined;
|
||||||
|
return row ? rowToBinding(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup used by the inbound mention handler: (platform, team, channel) → binding.
|
||||||
|
* Only returns 'active' bindings — disabled bindings must be invisible to the
|
||||||
|
* inbound path (fail-closed; the caller should treat a null return as "ignore").
|
||||||
|
*/
|
||||||
|
export function findActiveChatConnectorBinding(
|
||||||
|
db: Database.Database,
|
||||||
|
platform: ChatConnectorPlatform,
|
||||||
|
externalWorkspaceId: string,
|
||||||
|
externalChannelId: string,
|
||||||
|
): ChatConnectorBindingRecord | null {
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${SELECT_COLUMNS} FROM chat_connector_bindings
|
||||||
|
WHERE platform = ? AND external_workspace_id = ? AND external_channel_id = ? AND status = 'active'`,
|
||||||
|
)
|
||||||
|
.get(platform, externalWorkspaceId, externalChannelId) as ChatConnectorBindingRow | undefined;
|
||||||
|
return row ? rowToBinding(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every delegation the schema allows to back at most one space (validated at the API layer). */
|
||||||
|
export function listChatConnectorBindingsForDelegation(
|
||||||
|
db: Database.Database,
|
||||||
|
a2aDelegationId: string,
|
||||||
|
): ChatConnectorBindingRecord[] {
|
||||||
|
const rows = db
|
||||||
|
.prepare(`SELECT ${SELECT_COLUMNS} FROM chat_connector_bindings WHERE a2a_delegation_id = ?`)
|
||||||
|
.all(a2aDelegationId) as ChatConnectorBindingRow[];
|
||||||
|
return rows.map(rowToBinding);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateChatConnectorBinding(
|
||||||
|
db: Database.Database,
|
||||||
|
id: string,
|
||||||
|
patch: UpdateChatConnectorBindingInput,
|
||||||
|
): ChatConnectorBindingRecord | null {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const params: Array<string | number | Buffer> = [];
|
||||||
|
if (patch.botCredentialsEnc !== undefined) {
|
||||||
|
sets.push('bot_credentials_enc = ?');
|
||||||
|
params.push(patch.botCredentialsEnc);
|
||||||
|
}
|
||||||
|
if (patch.keyVersion !== undefined) {
|
||||||
|
sets.push('key_version = ?');
|
||||||
|
params.push(patch.keyVersion);
|
||||||
|
}
|
||||||
|
if (patch.status !== undefined) {
|
||||||
|
sets.push('status = ?');
|
||||||
|
params.push(patch.status);
|
||||||
|
}
|
||||||
|
if (sets.length === 0) return getChatConnectorBindingById(db, id);
|
||||||
|
sets.push("updated_at = datetime('now')");
|
||||||
|
params.push(id);
|
||||||
|
db.prepare(`UPDATE chat_connector_bindings SET ${sets.join(', ')} WHERE id = ?`).run(...params);
|
||||||
|
return getChatConnectorBindingById(db, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteChatConnectorBinding(db: Database.Database, id: string): void {
|
||||||
|
db.prepare('DELETE FROM chat_connector_bindings WHERE id = ?').run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Atomically reserves a Slack envelope. False means a retry already ran. */
|
||||||
|
export function claimChatConnectorEvent(db: Database.Database, eventId: string): boolean {
|
||||||
|
// Slack retries are bounded to minutes. Retaining a week absorbs delayed
|
||||||
|
// retries without letting an external event stream grow the DB forever.
|
||||||
|
db.prepare("DELETE FROM chat_connector_processed_events WHERE received_at < datetime('now', '-7 days')").run();
|
||||||
|
const result = db.prepare(
|
||||||
|
`INSERT INTO chat_connector_processed_events (event_id) VALUES (?)
|
||||||
|
ON CONFLICT(event_id) DO NOTHING`,
|
||||||
|
).run(eventId);
|
||||||
|
return result.changes === 1;
|
||||||
|
}
|
||||||
227
src/db/repositories/jobs.claim-pin.test.ts
Normal file
227
src/db/repositories/jobs.claim-pin.test.ts
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { Repository } from '../repository.js';
|
||||||
|
|
||||||
|
// Task 8: claim SQL 共通フラグメント化 + required_worker_id pin 条件。
|
||||||
|
// claimNextJob / claimNextRetryJob / peekNextClaimable の3クエリ(4箇所)が
|
||||||
|
// 同じマッチング条件(CLAIMABLE_MATCH_SQL)を共有していることを、pin あり/なし
|
||||||
|
// 双方のケースで確認する。
|
||||||
|
describe('claim SQL: required_worker_id pin (Phase 1 Task 8)', () => {
|
||||||
|
let dir: string;
|
||||||
|
let r: Repository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'jobs-claim-pin-'));
|
||||||
|
r = new Repository(join(dir, 'db.sqlite'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
r.close?.();
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seedWorker(opts: {
|
||||||
|
workerId: string;
|
||||||
|
roles: string[];
|
||||||
|
enabled?: boolean;
|
||||||
|
healthy?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
await r.upsertWorkerNode({
|
||||||
|
workerId: opts.workerId,
|
||||||
|
endpoint: `http://${opts.workerId}.local:11434`,
|
||||||
|
enabled: opts.enabled ?? true,
|
||||||
|
healthy: opts.healthy ?? true,
|
||||||
|
roles: opts.roles,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('claimNextJob', () => {
|
||||||
|
it('1) unpinned job: claimed by a worker whose profile_tags match required_profile (baseline unchanged)', async () => {
|
||||||
|
await seedWorker({ workerId: 'worker-auto-1', roles: ['auto'] });
|
||||||
|
const job = await r.createJob({
|
||||||
|
repo: 'local/pin-1',
|
||||||
|
issueNumber: 1,
|
||||||
|
instruction: 'do the thing',
|
||||||
|
pieceName: 'chat',
|
||||||
|
role: 'auto',
|
||||||
|
} as any);
|
||||||
|
expect(job.requiredWorkerId).toBeNull();
|
||||||
|
|
||||||
|
const claimed = await r.claimNextJob('worker-auto-1');
|
||||||
|
expect(claimed?.id).toBe(job.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2) pinned job: only the pinned worker can claim it; a different enabled worker with matching profile cannot', async () => {
|
||||||
|
await seedWorker({ workerId: 'worker-pinned', roles: ['auto'] });
|
||||||
|
await seedWorker({ workerId: 'worker-other', roles: ['auto'] });
|
||||||
|
const job = await r.createJob({
|
||||||
|
repo: 'local/pin-2',
|
||||||
|
issueNumber: 2,
|
||||||
|
instruction: 'do the thing',
|
||||||
|
pieceName: 'chat',
|
||||||
|
role: 'auto',
|
||||||
|
requiredWorkerId: 'worker-pinned',
|
||||||
|
} as any);
|
||||||
|
expect(job.requiredWorkerId).toBe('worker-pinned');
|
||||||
|
|
||||||
|
// The non-pinned worker must NOT be able to claim it, even though its
|
||||||
|
// profile_tags match required_profile.
|
||||||
|
const claimedByOther = await r.claimNextJob('worker-other');
|
||||||
|
expect(claimedByOther).toBeNull();
|
||||||
|
|
||||||
|
// The pinned worker CAN claim it.
|
||||||
|
const claimedByPinned = await r.claimNextJob('worker-pinned');
|
||||||
|
expect(claimedByPinned?.id).toBe(job.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3) pinned worker without any execution role (auto/fast/quality) cannot claim (stays queued)', async () => {
|
||||||
|
await seedWorker({ workerId: 'worker-reflection-only', roles: ['reflection'] });
|
||||||
|
const job = await r.createJob({
|
||||||
|
repo: 'local/pin-3',
|
||||||
|
issueNumber: 3,
|
||||||
|
instruction: 'do the thing',
|
||||||
|
pieceName: 'chat',
|
||||||
|
role: 'auto',
|
||||||
|
requiredWorkerId: 'worker-reflection-only',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const claimed = await r.claimNextJob('worker-reflection-only');
|
||||||
|
expect(claimed).toBeNull();
|
||||||
|
|
||||||
|
const stillQueued = await r.getJob(job.id);
|
||||||
|
expect(stillQueued?.status).toBe('queued');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3b) pinned worker with title-only role cannot claim either', async () => {
|
||||||
|
await seedWorker({ workerId: 'worker-title-only', roles: ['title'] });
|
||||||
|
const job = await r.createJob({
|
||||||
|
repo: 'local/pin-3b',
|
||||||
|
issueNumber: 4,
|
||||||
|
instruction: 'do the thing',
|
||||||
|
pieceName: 'chat',
|
||||||
|
role: 'auto',
|
||||||
|
requiredWorkerId: 'worker-title-only',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const claimed = await r.claimNextJob('worker-title-only');
|
||||||
|
expect(claimed).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('4) pinned worker disabled: not claimable', async () => {
|
||||||
|
await seedWorker({ workerId: 'worker-disabled', roles: ['auto'], enabled: false });
|
||||||
|
const job = await r.createJob({
|
||||||
|
repo: 'local/pin-4',
|
||||||
|
issueNumber: 5,
|
||||||
|
instruction: 'do the thing',
|
||||||
|
pieceName: 'chat',
|
||||||
|
role: 'auto',
|
||||||
|
requiredWorkerId: 'worker-disabled',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const claimed = await r.claimNextJob('worker-disabled');
|
||||||
|
expect(claimed).toBeNull();
|
||||||
|
|
||||||
|
const stillQueued = await r.getJob(job.id);
|
||||||
|
expect(stillQueued?.status).toBe('queued');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('claimNextRetryJob', () => {
|
||||||
|
async function seedRetryJob(params: {
|
||||||
|
repo: string;
|
||||||
|
issueNumber: number;
|
||||||
|
requiredWorkerId?: string | null;
|
||||||
|
}) {
|
||||||
|
const job = await r.createJob({
|
||||||
|
repo: params.repo,
|
||||||
|
issueNumber: params.issueNumber,
|
||||||
|
instruction: 'do the thing',
|
||||||
|
pieceName: 'chat',
|
||||||
|
role: 'auto',
|
||||||
|
requiredWorkerId: params.requiredWorkerId ?? null,
|
||||||
|
} as any);
|
||||||
|
const pastRetryAt = new Date(Date.now() - 60_000).toISOString();
|
||||||
|
await r.updateJob(job.id, { status: 'retry', nextRetryAt: pastRetryAt, attempt: 2 });
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('2) pinned retry job: only the pinned worker can claim it', async () => {
|
||||||
|
await seedWorker({ workerId: 'worker-pinned-r', roles: ['auto'] });
|
||||||
|
await seedWorker({ workerId: 'worker-other-r', roles: ['auto'] });
|
||||||
|
const job = await seedRetryJob({ repo: 'local/pin-retry-2', issueNumber: 1, requiredWorkerId: 'worker-pinned-r' });
|
||||||
|
|
||||||
|
const claimedByOther = await r.claimNextRetryJob('worker-other-r');
|
||||||
|
expect(claimedByOther).toBeNull();
|
||||||
|
|
||||||
|
const claimedByPinned = await r.claimNextRetryJob('worker-pinned-r');
|
||||||
|
expect(claimedByPinned?.id).toBe(job.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3) pinned retry job: worker without execution role cannot claim', async () => {
|
||||||
|
await seedWorker({ workerId: 'worker-reflection-r', roles: ['reflection'] });
|
||||||
|
await seedRetryJob({ repo: 'local/pin-retry-3', issueNumber: 2, requiredWorkerId: 'worker-reflection-r' });
|
||||||
|
|
||||||
|
const claimed = await r.claimNextRetryJob('worker-reflection-r');
|
||||||
|
expect(claimed).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('peekNextClaimable', () => {
|
||||||
|
it('2) pinned queued job: peek returns it for the pinned worker but not for another', async () => {
|
||||||
|
await seedWorker({ workerId: 'worker-pinned-p', roles: ['auto'] });
|
||||||
|
await seedWorker({ workerId: 'worker-other-p', roles: ['auto'] });
|
||||||
|
const job = await r.createJob({
|
||||||
|
repo: 'local/pin-peek-2',
|
||||||
|
issueNumber: 1,
|
||||||
|
instruction: 'do the thing',
|
||||||
|
pieceName: 'chat',
|
||||||
|
role: 'auto',
|
||||||
|
requiredWorkerId: 'worker-pinned-p',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const peekOther = await r.peekNextClaimable('worker-other-p');
|
||||||
|
expect(peekOther).toBeNull();
|
||||||
|
|
||||||
|
const peekPinned = await r.peekNextClaimable('worker-pinned-p');
|
||||||
|
expect(peekPinned?.id).toBe(job.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3) pinned queued job: worker without execution role does not peek it', async () => {
|
||||||
|
await seedWorker({ workerId: 'worker-reflection-p', roles: ['reflection'] });
|
||||||
|
await r.createJob({
|
||||||
|
repo: 'local/pin-peek-3',
|
||||||
|
issueNumber: 2,
|
||||||
|
instruction: 'do the thing',
|
||||||
|
pieceName: 'chat',
|
||||||
|
role: 'auto',
|
||||||
|
requiredWorkerId: 'worker-reflection-p',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const peek = await r.peekNextClaimable('worker-reflection-p');
|
||||||
|
expect(peek).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2r) pinned retry job takes priority in peek and is scoped to the pinned worker', async () => {
|
||||||
|
await seedWorker({ workerId: 'worker-pinned-pr', roles: ['auto'] });
|
||||||
|
await seedWorker({ workerId: 'worker-other-pr', roles: ['auto'] });
|
||||||
|
const job = await r.createJob({
|
||||||
|
repo: 'local/pin-peek-retry-2',
|
||||||
|
issueNumber: 3,
|
||||||
|
instruction: 'do the thing',
|
||||||
|
pieceName: 'chat',
|
||||||
|
role: 'auto',
|
||||||
|
requiredWorkerId: 'worker-pinned-pr',
|
||||||
|
} as any);
|
||||||
|
const pastRetryAt = new Date(Date.now() - 60_000).toISOString();
|
||||||
|
await r.updateJob(job.id, { status: 'retry', nextRetryAt: pastRetryAt, attempt: 2 });
|
||||||
|
|
||||||
|
const peekOther = await r.peekNextClaimable('worker-other-pr');
|
||||||
|
expect(peekOther).toBeNull();
|
||||||
|
|
||||||
|
const peekPinned = await r.peekNextClaimable('worker-pinned-pr');
|
||||||
|
expect(peekPinned?.id).toBe(job.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
55
src/db/repositories/jobs.llm-selection.test.ts
Normal file
55
src/db/repositories/jobs.llm-selection.test.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { Repository } from '../repository.js';
|
||||||
|
|
||||||
|
describe('jobs.required_worker_id / reasoning_effort (LLM selection Phase 1)', () => {
|
||||||
|
let dir: string;
|
||||||
|
let r: Repository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'jobs-llm-selection-'));
|
||||||
|
r = new Repository(join(dir, 'db.sqlite'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
r.close?.();
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createJob accepts requiredWorkerId + reasoningEffort and getJob round-trips both', async () => {
|
||||||
|
const created = await r.createJob({
|
||||||
|
repo: 'local/llm-select-1',
|
||||||
|
issueNumber: 1,
|
||||||
|
instruction: 'do the thing',
|
||||||
|
pieceName: 'chat',
|
||||||
|
requiredWorkerId: 'worker-gpu-2',
|
||||||
|
reasoningEffort: 'high',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(created.requiredWorkerId).toBe('worker-gpu-2');
|
||||||
|
expect(created.reasoningEffort).toBe('high');
|
||||||
|
|
||||||
|
const fetched = await r.getJob(created.id);
|
||||||
|
expect(fetched).toBeTruthy();
|
||||||
|
expect(fetched!.requiredWorkerId).toBe('worker-gpu-2');
|
||||||
|
expect(fetched!.reasoningEffort).toBe('high');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createJob defaults requiredWorkerId/reasoningEffort to null when omitted', async () => {
|
||||||
|
const created = await r.createJob({
|
||||||
|
repo: 'local/llm-select-2',
|
||||||
|
issueNumber: 2,
|
||||||
|
instruction: 'do the thing',
|
||||||
|
pieceName: 'chat',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(created.requiredWorkerId).toBeNull();
|
||||||
|
expect(created.reasoningEffort).toBeNull();
|
||||||
|
|
||||||
|
const fetched = await r.getJob(created.id);
|
||||||
|
expect(fetched!.requiredWorkerId).toBeNull();
|
||||||
|
expect(fetched!.reasoningEffort).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -62,6 +62,17 @@ export interface Job {
|
|||||||
requiredRole: JobRole;
|
requiredRole: JobRole;
|
||||||
/** @deprecated Use requiredRole */
|
/** @deprecated Use requiredRole */
|
||||||
requiredProfile: JobRole;
|
requiredProfile: JobRole;
|
||||||
|
/**
|
||||||
|
* LLM 選択 Phase 1: pinned worker id for this job. NULL = 従来の
|
||||||
|
* profile ルーティング(自動選択)。設定→ワーカー/エフォート選択で
|
||||||
|
* ユーザーが明示ピンした場合のみ非 null。claim SQL での消費は後続タスク。
|
||||||
|
*/
|
||||||
|
requiredWorkerId: string | null;
|
||||||
|
/**
|
||||||
|
* LLM 選択 Phase 1: per-job pinned reasoning effort (e.g. 'low' | 'medium' |
|
||||||
|
* 'high', worker の reasoning_efforts に依存). NULL = ワーカー既定を使用。
|
||||||
|
*/
|
||||||
|
reasoningEffort: string | null;
|
||||||
ownerId: string | null;
|
ownerId: string | null;
|
||||||
visibility: 'private' | 'org' | 'public';
|
visibility: 'private' | 'org' | 'public';
|
||||||
visibilityScopeOrgId: string | null;
|
visibilityScopeOrgId: string | null;
|
||||||
@ -104,6 +115,10 @@ export interface CreateJobParams {
|
|||||||
spaceId?: string | null;
|
spaceId?: string | null;
|
||||||
/** 実行ログ root(計画5)。NULL = 後方互換で workspacePath/logs に解決。 */
|
/** 実行ログ root(計画5)。NULL = 後方互換で workspacePath/logs に解決。 */
|
||||||
runtimeDir?: string | null;
|
runtimeDir?: string | null;
|
||||||
|
/** LLM 選択 Phase 1: pinned worker id。未指定/null は従来の profile ルーティング。 */
|
||||||
|
requiredWorkerId?: string | null;
|
||||||
|
/** LLM 選択 Phase 1: per-job pinned reasoning effort。未指定/null はワーカー既定。 */
|
||||||
|
reasoningEffort?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -148,6 +163,8 @@ export interface JobRow {
|
|||||||
subtask_depth: number;
|
subtask_depth: number;
|
||||||
required_profile: string;
|
required_profile: string;
|
||||||
task_class: string;
|
task_class: string;
|
||||||
|
required_worker_id: string | null;
|
||||||
|
reasoning_effort: string | null;
|
||||||
owner_id: string | null;
|
owner_id: string | null;
|
||||||
visibility: string | null;
|
visibility: string | null;
|
||||||
visibility_scope_org_id: string | null;
|
visibility_scope_org_id: string | null;
|
||||||
@ -209,6 +226,8 @@ export function rowToJob(row: JobRow): Job {
|
|||||||
subtaskDepth: row.subtask_depth ?? 0,
|
subtaskDepth: row.subtask_depth ?? 0,
|
||||||
requiredRole: normalizeJobRole(row.required_profile),
|
requiredRole: normalizeJobRole(row.required_profile),
|
||||||
requiredProfile: normalizeJobRole(row.required_profile),
|
requiredProfile: normalizeJobRole(row.required_profile),
|
||||||
|
requiredWorkerId: row.required_worker_id ?? null,
|
||||||
|
reasoningEffort: row.reasoning_effort ?? null,
|
||||||
ownerId: row.owner_id ?? null,
|
ownerId: row.owner_id ?? null,
|
||||||
visibility: (row.visibility === 'org' || row.visibility === 'public' ? row.visibility : 'private'),
|
visibility: (row.visibility === 'org' || row.visibility === 'public' ? row.visibility : 'private'),
|
||||||
visibilityScopeOrgId: row.visibility_scope_org_id ?? null,
|
visibilityScopeOrgId: row.visibility_scope_org_id ?? null,
|
||||||
@ -243,8 +262,8 @@ export function rowToJob(row: JobRow): Job {
|
|||||||
|
|
||||||
db
|
db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO jobs (id, repo, issue_number, status, piece_name, required_profile, task_class, instruction, attempt, max_attempts, resume_movement, ask_count, worker_id, parent_job_id, continued_from_job_id, subtask_depth, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, task_kind, payload, space_id, runtime_dir, created_at, updated_at)
|
`INSERT INTO jobs (id, repo, issue_number, status, piece_name, required_profile, task_class, instruction, attempt, max_attempts, resume_movement, ask_count, worker_id, parent_job_id, continued_from_job_id, subtask_depth, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, task_kind, payload, space_id, runtime_dir, required_worker_id, reasoning_effort, created_at, updated_at)
|
||||||
VALUES (@id, @repo, @issueNumber, 'queued', @pieceName, @requiredRole, 'auto', @instruction, 1, @maxAttempts, @resumeMovement, @askCount, NULL, @parentJobId, @continuedFromJobId, @subtaskDepth, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @taskKind, @payload, @spaceId, @runtimeDir, @now, @now)`
|
VALUES (@id, @repo, @issueNumber, 'queued', @pieceName, @requiredRole, 'auto', @instruction, 1, @maxAttempts, @resumeMovement, @askCount, NULL, @parentJobId, @continuedFromJobId, @subtaskDepth, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @taskKind, @payload, @spaceId, @runtimeDir, @requiredWorkerId, @reasoningEffort, @now, @now)`
|
||||||
)
|
)
|
||||||
.run({
|
.run({
|
||||||
id,
|
id,
|
||||||
@ -267,6 +286,8 @@ export function rowToJob(row: JobRow): Job {
|
|||||||
payload: params.payload ?? null,
|
payload: params.payload ?? null,
|
||||||
spaceId: params.spaceId ?? null,
|
spaceId: params.spaceId ?? null,
|
||||||
runtimeDir: params.runtimeDir ?? null,
|
runtimeDir: params.runtimeDir ?? null,
|
||||||
|
requiredWorkerId: params.requiredWorkerId ?? null,
|
||||||
|
reasoningEffort: params.reasoningEffort ?? null,
|
||||||
now,
|
now,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -566,6 +587,27 @@ export function rowToJob(row: JobRow): Job {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* queued/retry ジョブと worker のマッチング条件(claimNextJob /
|
||||||
|
* claimNextRetryJob / peekNextClaimable の計4クエリで共有)。
|
||||||
|
* - 直指定あり: そのワーカーのみ。かつ実行ロール(auto/fast/quality)を持つこと
|
||||||
|
* (title/reflection 専用ワーカーがユーザー指定で通常ジョブを掴む/永久 queued を防ぐ)
|
||||||
|
* - 直指定なし: 従来の required_profile マッチ
|
||||||
|
*/
|
||||||
|
const CLAIMABLE_MATCH_SQL = `
|
||||||
|
w.enabled = 1
|
||||||
|
AND w.healthy = 1
|
||||||
|
AND (
|
||||||
|
(j.required_worker_id IS NOT NULL
|
||||||
|
AND j.required_worker_id = w.worker_id
|
||||||
|
AND (instr(w.profile_tags, ',auto,') > 0
|
||||||
|
OR instr(w.profile_tags, ',fast,') > 0
|
||||||
|
OR instr(w.profile_tags, ',quality,') > 0))
|
||||||
|
OR (j.required_worker_id IS NULL
|
||||||
|
AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0)
|
||||||
|
)`;
|
||||||
|
|
||||||
|
|
||||||
export async function claimNextJob(db: Database.Database, workerId: string): Promise<Job | null> {
|
export async function claimNextJob(db: Database.Database, workerId: string): Promise<Job | null> {
|
||||||
const row = db.prepare(`
|
const row = db.prepare(`
|
||||||
UPDATE jobs
|
UPDATE jobs
|
||||||
@ -575,9 +617,7 @@ export function rowToJob(row: JobRow): Job {
|
|||||||
FROM jobs j
|
FROM jobs j
|
||||||
JOIN worker_nodes w ON w.worker_id = ?
|
JOIN worker_nodes w ON w.worker_id = ?
|
||||||
WHERE j.status = 'queued'
|
WHERE j.status = 'queued'
|
||||||
AND w.enabled = 1
|
AND ${CLAIMABLE_MATCH_SQL}
|
||||||
AND w.healthy = 1
|
|
||||||
AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0
|
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM issue_locks il
|
SELECT 1 FROM issue_locks il
|
||||||
WHERE il.repo = j.repo AND il.issue_number = j.issue_number
|
WHERE il.repo = j.repo AND il.issue_number = j.issue_number
|
||||||
@ -604,9 +644,7 @@ export function rowToJob(row: JobRow): Job {
|
|||||||
JOIN worker_nodes w ON w.worker_id = ?
|
JOIN worker_nodes w ON w.worker_id = ?
|
||||||
WHERE j.status = 'retry'
|
WHERE j.status = 'retry'
|
||||||
AND replace(j.next_retry_at, 'T', ' ') <= datetime('now')
|
AND replace(j.next_retry_at, 'T', ' ') <= datetime('now')
|
||||||
AND w.enabled = 1
|
AND ${CLAIMABLE_MATCH_SQL}
|
||||||
AND w.healthy = 1
|
|
||||||
AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0
|
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM issue_locks il
|
SELECT 1 FROM issue_locks il
|
||||||
WHERE il.repo = j.repo AND il.issue_number = j.issue_number
|
WHERE il.repo = j.repo AND il.issue_number = j.issue_number
|
||||||
@ -633,9 +671,7 @@ export function rowToJob(row: JobRow): Job {
|
|||||||
JOIN worker_nodes w ON w.worker_id = ?
|
JOIN worker_nodes w ON w.worker_id = ?
|
||||||
WHERE j.status = 'retry'
|
WHERE j.status = 'retry'
|
||||||
AND replace(j.next_retry_at, 'T', ' ') <= datetime('now')
|
AND replace(j.next_retry_at, 'T', ' ') <= datetime('now')
|
||||||
AND w.enabled = 1
|
AND ${CLAIMABLE_MATCH_SQL}
|
||||||
AND w.healthy = 1
|
|
||||||
AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0
|
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM issue_locks il
|
SELECT 1 FROM issue_locks il
|
||||||
WHERE il.repo = j.repo AND il.issue_number = j.issue_number
|
WHERE il.repo = j.repo AND il.issue_number = j.issue_number
|
||||||
@ -650,9 +686,7 @@ export function rowToJob(row: JobRow): Job {
|
|||||||
FROM jobs j
|
FROM jobs j
|
||||||
JOIN worker_nodes w ON w.worker_id = ?
|
JOIN worker_nodes w ON w.worker_id = ?
|
||||||
WHERE j.status = 'queued'
|
WHERE j.status = 'queued'
|
||||||
AND w.enabled = 1
|
AND ${CLAIMABLE_MATCH_SQL}
|
||||||
AND w.healthy = 1
|
|
||||||
AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0
|
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM issue_locks il
|
SELECT 1 FROM issue_locks il
|
||||||
WHERE il.repo = j.repo AND il.issue_number = j.issue_number
|
WHERE il.repo = j.repo AND il.issue_number = j.issue_number
|
||||||
@ -823,6 +857,43 @@ export function rowToJob(row: JobRow): Job {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLM 選択 Phase 1: PATCH /api/local/tasks/:id でタスクの sticky
|
||||||
|
* worker/effort が変わったとき、まだ実行が始まっていないジョブ(queued /
|
||||||
|
* retry)へ即反映する。running/dispatching/waiting_subtasks 中のジョブは
|
||||||
|
* 対象外(既に pinned worker で claim 済み・走行中に差し替えると齟齬が
|
||||||
|
* 出るため)。
|
||||||
|
* サブタスク(repo='subtask/<jobId>')は parent_job_id チェーンで
|
||||||
|
* タスク直下ジョブの子孫として再帰的に含める — SpawnSubTask がピンを
|
||||||
|
* 継承するため、子孫が queued のまま停滞ワーカーを抱え続けると「ピンを
|
||||||
|
* 解除すれば復旧する」という UI の案内が成立しなくなる(親が
|
||||||
|
* waiting_subtasks で永久に止まる)。visibility cascade
|
||||||
|
* (updateVisibilityForTaskCascade) と同じ再帰パターン。
|
||||||
|
*/
|
||||||
|
export async function updateJobsLlmSelectionForTask(
|
||||||
|
db: Database.Database,
|
||||||
|
taskId: number,
|
||||||
|
sel: { workerId: string | null; effort: string | null },
|
||||||
|
): Promise<void> {
|
||||||
|
const repoName = localTaskRepoName(taskId);
|
||||||
|
db
|
||||||
|
.prepare(`
|
||||||
|
WITH RECURSIVE task_tree(id) AS (
|
||||||
|
SELECT id FROM jobs WHERE repo = @repo AND issue_number = @taskId
|
||||||
|
UNION ALL
|
||||||
|
SELECT j.id FROM jobs j JOIN task_tree t ON j.parent_job_id = t.id
|
||||||
|
)
|
||||||
|
UPDATE jobs
|
||||||
|
SET required_worker_id = @workerId,
|
||||||
|
reasoning_effort = @effort,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE id IN (SELECT id FROM task_tree)
|
||||||
|
AND status IN ('queued','retry')
|
||||||
|
`)
|
||||||
|
.run({ repo: repoName, taskId, workerId: sel.workerId, effort: sel.effort });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function getSubJobs(db: Database.Database, parentJobId: string): Promise<Job[]> {
|
export async function getSubJobs(db: Database.Database, parentJobId: string): Promise<Job[]> {
|
||||||
// 同一 issue_number に複数ジョブがある場合(ASK再投入等)、最新のみ返す
|
// 同一 issue_number に複数ジョブがある場合(ASK再投入等)、最新のみ返す
|
||||||
// ROW_NUMBER() + rowid で同一 created_at でも一意に決定する
|
// ROW_NUMBER() + rowid で同一 created_at でも一意に決定する
|
||||||
|
|||||||
@ -69,6 +69,14 @@ export interface LocalTask {
|
|||||||
spaceId: string | null;
|
spaceId: string | null;
|
||||||
/** 実行ログ root(計画5)。null = 後方互換で workspacePath/logs に解決。 */
|
/** 実行ログ root(計画5)。null = 後方互換で workspacePath/logs に解決。 */
|
||||||
runtimeDir: string | null;
|
runtimeDir: string | null;
|
||||||
|
/**
|
||||||
|
* LLM 選択 Phase 1: タスク単位のスティッキー選択。follow-up コメントで
|
||||||
|
* 新規生成される job にも引き継がれる(引き継ぎ配線は claim SQL/worker 側の
|
||||||
|
* 後続タスク)。null = 未選択(従来の profile ルーティング)。
|
||||||
|
*/
|
||||||
|
llmWorkerId: string | null;
|
||||||
|
/** LLM 選択 Phase 1: タスク単位のスティッキー effort。null = 未選択。 */
|
||||||
|
llmEffort: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
feedbackRating: 'good' | 'bad' | null;
|
feedbackRating: 'good' | 'bad' | null;
|
||||||
@ -162,6 +170,13 @@ export interface CreateLocalTaskParams {
|
|||||||
spaceId?: string | null;
|
spaceId?: string | null;
|
||||||
/** Per-task runtime options (e.g. { mcpDisabled, skillsDisabled }). Stored as JSON. */
|
/** Per-task runtime options (e.g. { mcpDisabled, skillsDisabled }). Stored as JSON. */
|
||||||
options?: Record<string, unknown>;
|
options?: Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* LLM 選択 Phase 1: タスク作成時に pin する worker/effort。呼び出し側
|
||||||
|
* (local-tasks-crud-api.ts の POST) が insert 前に validateLlmSelection で
|
||||||
|
* 検証済みである前提。未指定/null は従来の profile ルーティング。
|
||||||
|
*/
|
||||||
|
llmWorkerId?: string | null;
|
||||||
|
llmEffort?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -179,6 +194,8 @@ export interface LocalTaskRow {
|
|||||||
workspace_path: string | null;
|
workspace_path: string | null;
|
||||||
workspace_mode: string | null;
|
workspace_mode: string | null;
|
||||||
runtime_dir: string | null;
|
runtime_dir: string | null;
|
||||||
|
llm_worker_id: string | null;
|
||||||
|
llm_effort: string | null;
|
||||||
owner_id: string | null;
|
owner_id: string | null;
|
||||||
owner_name?: string | null;
|
owner_name?: string | null;
|
||||||
visibility: string | null;
|
visibility: string | null;
|
||||||
@ -232,6 +249,8 @@ export function rowToLocalTask(row: LocalTaskRow): LocalTask {
|
|||||||
visibilityScopeOrgName: row.visibility_scope_org_name ?? null,
|
visibilityScopeOrgName: row.visibility_scope_org_name ?? null,
|
||||||
spaceId: row.space_id ?? null,
|
spaceId: row.space_id ?? null,
|
||||||
runtimeDir: row.runtime_dir ?? null,
|
runtimeDir: row.runtime_dir ?? null,
|
||||||
|
llmWorkerId: row.llm_worker_id ?? null,
|
||||||
|
llmEffort: row.llm_effort ?? null,
|
||||||
createdAt: utc(row.created_at),
|
createdAt: utc(row.created_at),
|
||||||
updatedAt: utc(row.updated_at),
|
updatedAt: utc(row.updated_at),
|
||||||
feedbackRating: (row.feedback_rating as 'good' | 'bad' | null) ?? null,
|
feedbackRating: (row.feedback_rating as 'good' | 'bad' | null) ?? null,
|
||||||
@ -329,8 +348,8 @@ export function parseCommentAttachments(raw: string | null): string[] {
|
|||||||
export async function createLocalTask(db: Database.Database, params: CreateLocalTaskParams): Promise<LocalTask> {
|
export async function createLocalTask(db: Database.Database, params: CreateLocalTaskParams): Promise<LocalTask> {
|
||||||
const result = db
|
const result = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO local_tasks (title, title_source, body, piece_name, profile, output_format, ask_policy, priority, workspace_path, workspace_mode, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, space_id, options)
|
`INSERT INTO local_tasks (title, title_source, body, piece_name, profile, output_format, ask_policy, priority, workspace_path, workspace_mode, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, space_id, options, llm_worker_id, llm_effort)
|
||||||
VALUES (@title, @titleSource, @body, @pieceName, @profile, @outputFormat, @askPolicy, @priority, @workspacePath, @workspaceMode, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @spaceId, @options)`
|
VALUES (@title, @titleSource, @body, @pieceName, @profile, @outputFormat, @askPolicy, @priority, @workspacePath, @workspaceMode, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @spaceId, @options, @llmWorkerId, @llmEffort)`
|
||||||
)
|
)
|
||||||
.run({
|
.run({
|
||||||
title: params.title,
|
title: params.title,
|
||||||
@ -349,6 +368,8 @@ export function parseCommentAttachments(raw: string | null): string[] {
|
|||||||
browserSessionProfileId: params.browserSessionProfileId ?? null,
|
browserSessionProfileId: params.browserSessionProfileId ?? null,
|
||||||
spaceId: params.spaceId ?? null,
|
spaceId: params.spaceId ?? null,
|
||||||
options: JSON.stringify(params.options ?? {}),
|
options: JSON.stringify(params.options ?? {}),
|
||||||
|
llmWorkerId: params.llmWorkerId ?? null,
|
||||||
|
llmEffort: params.llmEffort ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const task = await getLocalTask(db, Number(result.lastInsertRowid));
|
const task = await getLocalTask(db, Number(result.lastInsertRowid));
|
||||||
@ -680,6 +701,11 @@ export function parseCommentAttachments(raw: string | null): string[] {
|
|||||||
subtask_depth: row.job_subtask_depth ?? 0,
|
subtask_depth: row.job_subtask_depth ?? 0,
|
||||||
required_profile: row.job_required_profile!,
|
required_profile: row.job_required_profile!,
|
||||||
task_class: row.job_task_class!,
|
task_class: row.job_task_class!,
|
||||||
|
// この join は latestJob 表示用の縮小列セットで required_worker_id /
|
||||||
|
// reasoning_effort は取得していない。pin 済み表示が必要になったら
|
||||||
|
// 上の SELECT 列に j.required_worker_id / j.reasoning_effort を追加する。
|
||||||
|
required_worker_id: null,
|
||||||
|
reasoning_effort: null,
|
||||||
owner_id: row.job_owner_id ?? null,
|
owner_id: row.job_owner_id ?? null,
|
||||||
visibility: row.job_visibility ?? null,
|
visibility: row.job_visibility ?? null,
|
||||||
visibility_scope_org_id: row.job_visibility_scope_org_id ?? null,
|
visibility_scope_org_id: row.job_visibility_scope_org_id ?? null,
|
||||||
@ -764,6 +790,8 @@ export function parseCommentAttachments(raw: string | null): string[] {
|
|||||||
runtimeDir: 'runtime_dir',
|
runtimeDir: 'runtime_dir',
|
||||||
visibility: 'visibility',
|
visibility: 'visibility',
|
||||||
visibilityScopeOrgId: 'visibility_scope_org_id',
|
visibilityScopeOrgId: 'visibility_scope_org_id',
|
||||||
|
llmWorkerId: 'llm_worker_id',
|
||||||
|
llmEffort: 'llm_effort',
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [jsKey, dbCol] of Object.entries(fieldMap)) {
|
for (const [jsKey, dbCol] of Object.entries(fieldMap)) {
|
||||||
|
|||||||
46
src/db/repositories/reminders.ts
Normal file
46
src/db/repositories/reminders.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
export interface AgentReminder {
|
||||||
|
id: string;
|
||||||
|
ownerId: string | null;
|
||||||
|
spaceId: string | null;
|
||||||
|
title: string | null;
|
||||||
|
body: string;
|
||||||
|
imageUrl: string | null;
|
||||||
|
dueAt: string;
|
||||||
|
deliveredAt: string | null;
|
||||||
|
cancelledAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromRow(row: Record<string, unknown>): AgentReminder {
|
||||||
|
return {
|
||||||
|
id: String(row.id), ownerId: row.owner_id as string | null, spaceId: row.space_id as string | null,
|
||||||
|
title: row.title as string | null, body: String(row.body), imageUrl: row.image_url as string | null,
|
||||||
|
dueAt: String(row.due_at), deliveredAt: row.delivered_at as string | null,
|
||||||
|
cancelledAt: row.cancelled_at as string | null, createdAt: String(row.created_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAgentReminder(db: Database.Database, input: { ownerId?: string | null; spaceId?: string | null; title?: string | null; body: string; imageUrl?: string | null; dueAt: string }): AgentReminder {
|
||||||
|
const id = randomUUID();
|
||||||
|
db.prepare('INSERT INTO agent_reminders (id, owner_id, space_id, title, body, image_url, due_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||||
|
.run(id, input.ownerId ?? null, input.spaceId ?? null, input.title ?? null, input.body, input.imageUrl ?? null, input.dueAt);
|
||||||
|
return fromRow(db.prepare('SELECT * FROM agent_reminders WHERE id = ?').get(id) as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAgentReminders(db: Database.Database, ownerId: string | null | undefined, spaceId?: string | null): AgentReminder[] {
|
||||||
|
const conditions = ['cancelled_at IS NULL', 'delivered_at IS NULL'];
|
||||||
|
const args: unknown[] = [];
|
||||||
|
if (ownerId == null) conditions.push('owner_id IS NULL');
|
||||||
|
else { conditions.push('owner_id = ?'); args.push(ownerId); }
|
||||||
|
if (spaceId != null) { conditions.push('space_id = ?'); args.push(spaceId); }
|
||||||
|
return (db.prepare(`SELECT * FROM agent_reminders WHERE ${conditions.join(' AND ')} ORDER BY due_at ASC`).all(...args) as Record<string, unknown>[]).map(fromRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelAgentReminder(db: Database.Database, id: string, ownerId: string | null | undefined): boolean {
|
||||||
|
const scope = ownerId == null ? 'owner_id IS NULL' : 'owner_id = ?';
|
||||||
|
const args = ownerId == null ? [id] : [id, ownerId];
|
||||||
|
return db.prepare(`UPDATE agent_reminders SET cancelled_at = datetime('now') WHERE id = ? AND ${scope} AND delivered_at IS NULL AND cancelled_at IS NULL`).run(...args).changes > 0;
|
||||||
|
}
|
||||||
@ -159,6 +159,8 @@ const __dirname = dirname(__filename);
|
|||||||
end_date TEXT,
|
end_date TEXT,
|
||||||
time TEXT,
|
time TEXT,
|
||||||
end_time TEXT,
|
end_time TEXT,
|
||||||
|
reminder_minutes INTEGER,
|
||||||
|
reminder_delivered_at TEXT,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
created_by TEXT NOT NULL DEFAULT 'user',
|
created_by TEXT NOT NULL DEFAULT 'user',
|
||||||
@ -167,6 +169,13 @@ const __dirname = dirname(__filename);
|
|||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_calendar_events_space_date ON calendar_events (space_id, date);
|
CREATE INDEX IF NOT EXISTS idx_calendar_events_space_date ON calendar_events (space_id, date);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_reminders (
|
||||||
|
id TEXT PRIMARY KEY, owner_id TEXT, space_id TEXT,
|
||||||
|
title TEXT, body TEXT NOT NULL, image_url TEXT, due_at TEXT NOT NULL,
|
||||||
|
delivered_at TEXT, cancelled_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_reminders_due ON agent_reminders (due_at) WHERE delivered_at IS NULL AND cancelled_at IS NULL;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// スペース招待リンク(再利用トークン)。schema.sql / migrate.ts と三重ミラー。
|
// スペース招待リンク(再利用トークン)。schema.sql / migrate.ts と三重ミラー。
|
||||||
@ -470,6 +479,14 @@ const __dirname = dirname(__filename);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_llm_usage_hourly_user_hour
|
CREATE INDEX IF NOT EXISTS idx_llm_usage_hourly_user_hour
|
||||||
ON llm_usage_hourly (user_id, hour);
|
ON llm_usage_hourly (user_id, hour);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// LLM selection Phase 1: sticky worker/effort pin. Mirrors schema.sql +
|
||||||
|
// migrate.ts (dual-path rule) — this is the third compat-upgrade path
|
||||||
|
// that runs on every bare `new Repository()` (no runMigrations call).
|
||||||
|
ensureColumn(db, 'jobs', 'required_worker_id', 'TEXT');
|
||||||
|
ensureColumn(db, 'jobs', 'reasoning_effort', 'TEXT');
|
||||||
|
ensureColumn(db, 'local_tasks', 'llm_worker_id', 'TEXT');
|
||||||
|
ensureColumn(db, 'local_tasks', 'llm_effort', 'TEXT');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,16 @@ describe('Repository calendar_events', () => {
|
|||||||
expect(got?.title).toBe('kickoff');
|
expect(got?.title).toBe('kickoff');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('claims a due reminder only once', async () => {
|
||||||
|
const event = await repo.createCalendarEvent({
|
||||||
|
spaceId: 'space-A', date: '2026-07-10', time: '10:00', title: 'remind', reminderMinutes: 10,
|
||||||
|
});
|
||||||
|
expect(await repo.claimDueCalendarReminders('space-A', '2026-07-10 09:49')).toEqual([]);
|
||||||
|
const first = await repo.claimDueCalendarReminders('space-A', '2026-07-10 09:50');
|
||||||
|
expect(first.map(item => item.id)).toEqual([event.id]);
|
||||||
|
expect(await repo.claimDueCalendarReminders('space-A', '2026-07-10 10:00')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('defaults: createdBy=user, all-day (time null), description null', async () => {
|
it('defaults: createdBy=user, all-day (time null), description null', async () => {
|
||||||
const ev = await repo.createCalendarEvent({ spaceId: 'space-A', date: '2026-06-20', title: 'allday' });
|
const ev = await repo.createCalendarEvent({ spaceId: 'space-A', date: '2026-06-20', title: 'allday' });
|
||||||
expect(ev.createdBy).toBe('user');
|
expect(ev.createdBy).toBe('user');
|
||||||
|
|||||||
61
src/db/repository.llm-selection.test.ts
Normal file
61
src/db/repository.llm-selection.test.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { Repository } from './repository.js';
|
||||||
|
|
||||||
|
let tempDir = '';
|
||||||
|
let dbPath = '';
|
||||||
|
let repo: Repository;
|
||||||
|
|
||||||
|
async function seedTask(): Promise<number> {
|
||||||
|
const created = await repo.createLocalTask({
|
||||||
|
title: 'seed',
|
||||||
|
body: 'do the thing',
|
||||||
|
pieceName: 'auto',
|
||||||
|
ownerId: 'owner-1',
|
||||||
|
visibility: 'private',
|
||||||
|
});
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), 'maestro-llm-selection-'));
|
||||||
|
dbPath = join(tempDir, 'db.sqlite');
|
||||||
|
repo = new Repository(dbPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
repo.close();
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('local_tasks.llm_worker_id / llm_effort (LLM selection Phase 1)', () => {
|
||||||
|
it('is null on a freshly created task', async () => {
|
||||||
|
const taskId = await seedTask();
|
||||||
|
const task = await repo.getLocalTask(taskId);
|
||||||
|
expect(task).toBeTruthy();
|
||||||
|
expect(task!.llmWorkerId).toBeNull();
|
||||||
|
expect(task!.llmEffort).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateLocalTask sets llmWorkerId/llmEffort and getLocalTask reads them back', async () => {
|
||||||
|
const taskId = await seedTask();
|
||||||
|
await repo.updateLocalTask(taskId, { llmWorkerId: 'worker-gpu-1', llmEffort: 'medium' } as any);
|
||||||
|
|
||||||
|
const task = await repo.getLocalTask(taskId);
|
||||||
|
expect(task).toBeTruthy();
|
||||||
|
expect(task!.llmWorkerId).toBe('worker-gpu-1');
|
||||||
|
expect(task!.llmEffort).toBe('medium');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateLocalTask can clear a pinned worker/effort back to null', async () => {
|
||||||
|
const taskId = await seedTask();
|
||||||
|
await repo.updateLocalTask(taskId, { llmWorkerId: 'worker-gpu-1', llmEffort: 'high' } as any);
|
||||||
|
await repo.updateLocalTask(taskId, { llmWorkerId: null, llmEffort: null } as any);
|
||||||
|
|
||||||
|
const task = await repo.getLocalTask(taskId);
|
||||||
|
expect(task!.llmWorkerId).toBeNull();
|
||||||
|
expect(task!.llmEffort).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
92
src/db/repository.reminders.test.ts
Normal file
92
src/db/repository.reminders.test.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { Repository } from './repository.js';
|
||||||
|
import { runMigrations } from './migrate.js';
|
||||||
|
|
||||||
|
describe('agent_reminders schema and repository', () => {
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'reminders-repo-test-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Repository.initSchema creates agent_reminders with the expected columns', () => {
|
||||||
|
const repo = new Repository(join(dir, 'fresh.db'));
|
||||||
|
const cols = repo.getDb().prepare("PRAGMA table_info('agent_reminders')").all() as Array<{ name: string }>;
|
||||||
|
expect(cols.map((c) => c.name)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'id',
|
||||||
|
'owner_id',
|
||||||
|
'space_id',
|
||||||
|
'title',
|
||||||
|
'body',
|
||||||
|
'image_url',
|
||||||
|
'due_at',
|
||||||
|
'delivered_at',
|
||||||
|
'cancelled_at',
|
||||||
|
'created_at',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const indexes = repo.getDb().prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_agent_reminders_due'").all() as Array<{ name: string }>;
|
||||||
|
expect(indexes).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runMigrations creates agent_reminders on an upgrade path DB', () => {
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
runMigrations(db);
|
||||||
|
const cols = db.prepare("PRAGMA table_info('agent_reminders')").all() as Array<{ name: string }>;
|
||||||
|
expect(cols.map((c) => c.name)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'id',
|
||||||
|
'owner_id',
|
||||||
|
'space_id',
|
||||||
|
'title',
|
||||||
|
'body',
|
||||||
|
'image_url',
|
||||||
|
'due_at',
|
||||||
|
'delivered_at',
|
||||||
|
'cancelled_at',
|
||||||
|
'created_at',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_agent_reminders_due'").all() as Array<{ name: string }>;
|
||||||
|
expect(indexes).toHaveLength(1);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores, filters, and cancels reminders through the repository facade', () => {
|
||||||
|
const repo = new Repository(join(dir, 'crud.db'));
|
||||||
|
const reminder = repo.createAgentReminder({
|
||||||
|
ownerId: 'u1',
|
||||||
|
spaceId: 'space-a',
|
||||||
|
title: '休憩',
|
||||||
|
body: 'stretch',
|
||||||
|
imageUrl: 'https://example.com/pin.png',
|
||||||
|
dueAt: '2026-07-10T06:30:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const listed = repo.listAgentReminders('u1', 'space-a');
|
||||||
|
expect(listed).toHaveLength(1);
|
||||||
|
expect(listed[0]).toMatchObject({
|
||||||
|
id: reminder.id,
|
||||||
|
ownerId: 'u1',
|
||||||
|
spaceId: 'space-a',
|
||||||
|
title: '休憩',
|
||||||
|
body: 'stretch',
|
||||||
|
imageUrl: 'https://example.com/pin.png',
|
||||||
|
dueAt: '2026-07-10T06:30:00.000Z',
|
||||||
|
deliveredAt: null,
|
||||||
|
cancelledAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(repo.cancelAgentReminder(reminder.id, 'u1')).toBe(true);
|
||||||
|
expect(repo.listAgentReminders('u1', 'space-a')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -24,6 +24,8 @@ import * as notificationsRepo from './repositories/notifications.js';
|
|||||||
import { PushSubscriptionRecord, UpsertPushSubscriptionInput, NotificationPrefs, NotificationPrefsUpdate } from './repositories/notifications.js';
|
import { PushSubscriptionRecord, UpsertPushSubscriptionInput, NotificationPrefs, NotificationPrefsUpdate } from './repositories/notifications.js';
|
||||||
import * as webhooksRepo from './repositories/webhooks.js';
|
import * as webhooksRepo from './repositories/webhooks.js';
|
||||||
import { SpaceWebhookRecord, CreateSpaceWebhookInput, UpdateSpaceWebhookInput } from './repositories/webhooks.js';
|
import { SpaceWebhookRecord, CreateSpaceWebhookInput, UpdateSpaceWebhookInput } from './repositories/webhooks.js';
|
||||||
|
import * as chatConnectorsRepo from './repositories/chat-connectors.js';
|
||||||
|
import { ChatConnectorBindingRecord, CreateChatConnectorBindingInput, UpdateChatConnectorBindingInput } from './repositories/chat-connectors.js';
|
||||||
import * as auditRepo from './repositories/audit.js';
|
import * as auditRepo from './repositories/audit.js';
|
||||||
import * as appSharesRepo from './repositories/app-shares.js';
|
import * as appSharesRepo from './repositories/app-shares.js';
|
||||||
import * as provenanceRepo from './repositories/provenance.js';
|
import * as provenanceRepo from './repositories/provenance.js';
|
||||||
@ -34,10 +36,14 @@ export type { TitleSource, LocalTask, MissionBriefField, MissionBrief, LocalTask
|
|||||||
import * as taskSearchRepo from './repositories/task-search.js';
|
import * as taskSearchRepo from './repositories/task-search.js';
|
||||||
import * as calendarRepo from './repositories/calendar.js';
|
import * as calendarRepo from './repositories/calendar.js';
|
||||||
import { CalendarEvent, CalendarDayCounts, CalendarDayTask, CrossSpaceCalendarMonth } from './repositories/calendar.js';
|
import { CalendarEvent, CalendarDayCounts, CalendarDayTask, CrossSpaceCalendarMonth } from './repositories/calendar.js';
|
||||||
|
import * as remindersRepo from './repositories/reminders.js';
|
||||||
|
import { AgentReminder } from './repositories/reminders.js';
|
||||||
export { enumerateLocalDays } from './repositories/calendar.js';
|
export { enumerateLocalDays } from './repositories/calendar.js';
|
||||||
export type { CalendarEvent, CalendarDayCounts, CalendarDayTask, CrossCalendarSpace, CrossSpaceCalendarMonth } from './repositories/calendar.js';
|
export type { CalendarEvent, CalendarDayCounts, CalendarDayTask, CrossCalendarSpace, CrossSpaceCalendarMonth } from './repositories/calendar.js';
|
||||||
|
export type { AgentReminder } from './repositories/reminders.js';
|
||||||
export type { NotifyEventType, PushSubscriptionRecord, UpsertPushSubscriptionInput, NotificationPrefs, NotificationPrefsUpdate } from './repositories/notifications.js';
|
export type { NotifyEventType, PushSubscriptionRecord, UpsertPushSubscriptionInput, NotificationPrefs, NotificationPrefsUpdate } from './repositories/notifications.js';
|
||||||
export type { WebhookProvider, WebhookEventType, SpaceWebhookRecord, CreateSpaceWebhookInput, UpdateSpaceWebhookInput } from './repositories/webhooks.js';
|
export type { WebhookProvider, WebhookEventType, SpaceWebhookRecord, CreateSpaceWebhookInput, UpdateSpaceWebhookInput } from './repositories/webhooks.js';
|
||||||
|
export type { ChatConnectorPlatform, ChatConnectorBindingStatus, ChatConnectorBindingRecord, CreateChatConnectorBindingInput, UpdateChatConnectorBindingInput } from './repositories/chat-connectors.js';
|
||||||
export type { LlmUsageIncrement, LlmUsageDailyAgg, LlmUsageHourlyIncrement, LlmUsageHourlyRow } from './repositories/llm-usage.js';
|
export type { LlmUsageIncrement, LlmUsageDailyAgg, LlmUsageHourlyIncrement, LlmUsageHourlyRow } from './repositories/llm-usage.js';
|
||||||
export type { GatewayVirtualKeySource, GatewayVirtualKey, GatewayKeyUsage } from './repositories/gateway.js';
|
export type { GatewayVirtualKeySource, GatewayVirtualKey, GatewayKeyUsage } from './repositories/gateway.js';
|
||||||
export type { ToolRequestCategory, ToolRequestStatus, ToolRequest, ToolRequestAggregate, PackageRequest, PackageRequestStatus } from './repositories/tool-requests.js';
|
export type { ToolRequestCategory, ToolRequestStatus, ToolRequest, ToolRequestAggregate, PackageRequest, PackageRequestStatus } from './repositories/tool-requests.js';
|
||||||
@ -615,6 +621,10 @@ export class Repository {
|
|||||||
return jobsRepo.updateJobsVisibilityForTask(this.db, taskId, updates);
|
return jobsRepo.updateJobsVisibilityForTask(this.db, taskId, updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateJobsLlmSelectionForTask(taskId: number, sel: { workerId: string | null; effort: string | null }): Promise<void> {
|
||||||
|
return jobsRepo.updateJobsLlmSelectionForTask(this.db, taskId, sel);
|
||||||
|
}
|
||||||
|
|
||||||
async getSubJobs(parentJobId: string): Promise<Job[]> {
|
async getSubJobs(parentJobId: string): Promise<Job[]> {
|
||||||
return jobsRepo.getSubJobs(this.db, parentJobId);
|
return jobsRepo.getSubJobs(this.db, parentJobId);
|
||||||
}
|
}
|
||||||
@ -842,6 +852,7 @@ export class Repository {
|
|||||||
endDate?: string | null;
|
endDate?: string | null;
|
||||||
time?: string | null;
|
time?: string | null;
|
||||||
endTime?: string | null;
|
endTime?: string | null;
|
||||||
|
reminderMinutes?: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
createdBy?: 'user' | 'agent';
|
createdBy?: 'user' | 'agent';
|
||||||
@ -932,7 +943,7 @@ export class Repository {
|
|||||||
return calendarRepo.getCalendarEvent(this.db, eventId);
|
return calendarRepo.getCalendarEvent(this.db, eventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateCalendarEvent(eventId: number, fields: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null }): Promise<CalendarEvent | null> {
|
async updateCalendarEvent(eventId: number, fields: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; reminderMinutes?: number | null; title?: string; description?: string | null }): Promise<CalendarEvent | null> {
|
||||||
return calendarRepo.updateCalendarEvent(this.db, eventId, fields);
|
return calendarRepo.updateCalendarEvent(this.db, eventId, fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -940,6 +951,22 @@ export class Repository {
|
|||||||
return calendarRepo.deleteCalendarEvent(this.db, eventId);
|
return calendarRepo.deleteCalendarEvent(this.db, eventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createAgentReminder(input: { ownerId?: string | null; spaceId?: string | null; title?: string | null; body: string; imageUrl?: string | null; dueAt: string }): AgentReminder {
|
||||||
|
return remindersRepo.createAgentReminder(this.db, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
listAgentReminders(ownerId: string | null | undefined, spaceId?: string | null): AgentReminder[] {
|
||||||
|
return remindersRepo.listAgentReminders(this.db, ownerId, spaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelAgentReminder(id: string, ownerId: string | null | undefined): boolean {
|
||||||
|
return remindersRepo.cancelAgentReminder(this.db, id, ownerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async claimDueCalendarReminders(spaceId: string, nowLocal: string): Promise<CalendarEvent[]> {
|
||||||
|
return calendarRepo.claimDueCalendarReminders(this.db, spaceId, nowLocal);
|
||||||
|
}
|
||||||
|
|
||||||
private calendarTaskScope(spaceId: string, personalOwnerId?: string | null, alias = ''): { clause: string; params: unknown[] } {
|
private calendarTaskScope(spaceId: string, personalOwnerId?: string | null, alias = ''): { clause: string; params: unknown[] } {
|
||||||
return calendarRepo.calendarTaskScope(spaceId, personalOwnerId, alias);
|
return calendarRepo.calendarTaskScope(spaceId, personalOwnerId, alias);
|
||||||
}
|
}
|
||||||
@ -1130,6 +1157,43 @@ export class Repository {
|
|||||||
return webhooksRepo.markSpaceWebhookFailure(this.db, id);
|
return webhooksRepo.markSpaceWebhookFailure(this.db, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Chat connector bindings (issue #801, PR1 — Slack MVP) ──────────────
|
||||||
|
createChatConnectorBinding(input: CreateChatConnectorBindingInput): ChatConnectorBindingRecord {
|
||||||
|
return chatConnectorsRepo.createChatConnectorBinding(this.db, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
listChatConnectorBindings(): ChatConnectorBindingRecord[] {
|
||||||
|
return chatConnectorsRepo.listChatConnectorBindings(this.db);
|
||||||
|
}
|
||||||
|
|
||||||
|
getChatConnectorBindingById(id: string): ChatConnectorBindingRecord | null {
|
||||||
|
return chatConnectorsRepo.getChatConnectorBindingById(this.db, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
findActiveChatConnectorBinding(
|
||||||
|
platform: chatConnectorsRepo.ChatConnectorPlatform,
|
||||||
|
externalWorkspaceId: string,
|
||||||
|
externalChannelId: string,
|
||||||
|
): ChatConnectorBindingRecord | null {
|
||||||
|
return chatConnectorsRepo.findActiveChatConnectorBinding(this.db, platform, externalWorkspaceId, externalChannelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
listChatConnectorBindingsForDelegation(a2aDelegationId: string): ChatConnectorBindingRecord[] {
|
||||||
|
return chatConnectorsRepo.listChatConnectorBindingsForDelegation(this.db, a2aDelegationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateChatConnectorBinding(id: string, patch: UpdateChatConnectorBindingInput): ChatConnectorBindingRecord | null {
|
||||||
|
return chatConnectorsRepo.updateChatConnectorBinding(this.db, id, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteChatConnectorBinding(id: string): void {
|
||||||
|
return chatConnectorsRepo.deleteChatConnectorBinding(this.db, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
claimChatConnectorEvent(eventId: string): boolean {
|
||||||
|
return chatConnectorsRepo.claimChatConnectorEvent(this.db, eventId);
|
||||||
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
this.db.close();
|
this.db.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,6 +34,10 @@ CREATE TABLE IF NOT EXISTS jobs (
|
|||||||
space_id TEXT,
|
space_id TEXT,
|
||||||
-- 実行ログ root(計画5)。NULL = 後方互換で workspace_path/logs に解決。
|
-- 実行ログ root(計画5)。NULL = 後方互換で workspace_path/logs に解決。
|
||||||
runtime_dir TEXT,
|
runtime_dir TEXT,
|
||||||
|
-- LLM 選択 Phase 1: per-job worker/effort pin。NULL = 従来の profile ルーティング。
|
||||||
|
-- spec: docs/superpowers/specs/2026-07-09-llm-selection-effort-design.md
|
||||||
|
required_worker_id TEXT,
|
||||||
|
reasoning_effort TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
@ -82,7 +86,13 @@ CREATE TABLE IF NOT EXISTS local_tasks (
|
|||||||
-- 実行先ワークスペースの種別。'persistent'(既定) / 'ephemeral'(使い捨て)(計画3)
|
-- 実行先ワークスペースの種別。'persistent'(既定) / 'ephemeral'(使い捨て)(計画3)
|
||||||
workspace_mode TEXT NOT NULL DEFAULT 'persistent',
|
workspace_mode TEXT NOT NULL DEFAULT 'persistent',
|
||||||
-- 実行ログ root(計画5)。NULL = 後方互換で workspace_path/logs に解決。
|
-- 実行ログ root(計画5)。NULL = 後方互換で workspace_path/logs に解決。
|
||||||
runtime_dir TEXT
|
runtime_dir TEXT,
|
||||||
|
-- LLM 選択 Phase 1: タスク単位のスティッキー選択。follow-up コメントで
|
||||||
|
-- 生成される後続 job にも引き継がれる(引き継ぎ配線は後続タスク)。
|
||||||
|
-- NULL = 未選択(従来の profile ルーティング)。
|
||||||
|
-- spec: docs/superpowers/specs/2026-07-09-llm-selection-effort-design.md
|
||||||
|
llm_worker_id TEXT,
|
||||||
|
llm_effort TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_local_tasks_updated_at ON local_tasks (updated_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_local_tasks_updated_at ON local_tasks (updated_at DESC);
|
||||||
@ -156,6 +166,8 @@ CREATE TABLE IF NOT EXISTS calendar_events (
|
|||||||
end_date TEXT, -- 終了日 YYYY-MM-DD。NULL = date と同じ(単日)
|
end_date TEXT, -- 終了日 YYYY-MM-DD。NULL = date と同じ(単日)
|
||||||
time TEXT, -- 開始 HH:MM。NULL = 終日
|
time TEXT, -- 開始 HH:MM。NULL = 終日
|
||||||
end_time TEXT, -- 終了 HH:MM。NULL = 終了時刻なし(time が NULL なら常に NULL)
|
end_time TEXT, -- 終了 HH:MM。NULL = 終了時刻なし(time が NULL なら常に NULL)
|
||||||
|
reminder_minutes INTEGER, -- 開始何分前に通知するか。NULL = 通知なし
|
||||||
|
reminder_delivered_at TEXT, -- 配信済み UTC。予定/通知設定変更時に NULL へ戻す
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
created_by TEXT NOT NULL DEFAULT 'user', -- 'user' / 'agent'
|
created_by TEXT NOT NULL DEFAULT 'user', -- 'user' / 'agent'
|
||||||
@ -166,6 +178,20 @@ CREATE TABLE IF NOT EXISTS calendar_events (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_calendar_events_space_date ON calendar_events (space_id, date);
|
CREATE INDEX IF NOT EXISTS idx_calendar_events_space_date ON calendar_events (space_id, date);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_reminders (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT,
|
||||||
|
space_id TEXT,
|
||||||
|
title TEXT,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
image_url TEXT,
|
||||||
|
due_at TEXT NOT NULL,
|
||||||
|
delivered_at TEXT,
|
||||||
|
cancelled_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_reminders_due ON agent_reminders (due_at) WHERE delivered_at IS NULL AND cancelled_at IS NULL;
|
||||||
|
|
||||||
-- ─── スペース招待リンク(再利用トークン)─────────────────────────────
|
-- ─── スペース招待リンク(再利用トークン)─────────────────────────────
|
||||||
-- 組織非依存の招待経路。canManageSpace が発行し、リンクを知る相手が参加する。
|
-- 組織非依存の招待経路。canManageSpace が発行し、リンクを知る相手が参加する。
|
||||||
-- スペースごとに有効リンクは最大1本(再生成で旧 invite を削除して回す)。
|
-- スペースごとに有効リンクは最大1本(再生成で旧 invite を削除して回す)。
|
||||||
@ -935,6 +961,44 @@ CREATE TABLE IF NOT EXISTS space_webhooks (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_space_webhooks_space_id ON space_webhooks(space_id);
|
CREATE INDEX IF NOT EXISTS idx_space_webhooks_space_id ON space_webhooks(space_id);
|
||||||
|
|
||||||
|
-- Chat connector bindings (issue #801, PR1 — Slack MVP): links an external chat
|
||||||
|
-- channel to a MAESTRO space + an existing A2A delegation. The chat-connector
|
||||||
|
-- service reuses the delegation's AND-intersected scope (space/skills) and the
|
||||||
|
-- A2A governance limiter — no separate authz path. bot_credentials_enc is an
|
||||||
|
-- AES-256-GCM envelope-encrypted BLOB (src/mcp/crypto.ts, same scheme as
|
||||||
|
-- space_webhooks.url_enc) holding the Slack signing secret + bot token JSON.
|
||||||
|
-- Never decrypted in this table's repo module; only chat-connector-service.ts
|
||||||
|
-- decrypts just-in-time and never logs/returns the plaintext.
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_connector_bindings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
platform TEXT NOT NULL DEFAULT 'slack', -- 'slack' (PR1 only)
|
||||||
|
external_workspace_id TEXT NOT NULL, -- Slack team id
|
||||||
|
external_channel_id TEXT NOT NULL,
|
||||||
|
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||||
|
a2a_client_id TEXT NOT NULL, -- a2a_clients.client_id
|
||||||
|
a2a_delegation_id TEXT NOT NULL, -- a2a_delegations.id
|
||||||
|
a2a_grant_id TEXT NOT NULL, -- a2a_delegations.grant_id (snapshot)
|
||||||
|
bot_credentials_enc BLOB NOT NULL,
|
||||||
|
key_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'disabled'
|
||||||
|
created_by TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_bindings_channel
|
||||||
|
ON chat_connector_bindings (platform, external_workspace_id, external_channel_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_bindings_space ON chat_connector_bindings (space_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_bindings_delegation ON chat_connector_bindings (a2a_delegation_id);
|
||||||
|
|
||||||
|
-- Slack can retry a successfully delivered Events API envelope. Claiming its
|
||||||
|
-- event_id before dispatch keeps task creation and replies exactly-once.
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_connector_processed_events (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
received_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_processed_events_received_at
|
||||||
|
ON chat_connector_processed_events (received_at);
|
||||||
|
|
||||||
-- Audit 2026-06-08 fix: user_gitea_orgs (per-user Gitea org cache) was created
|
-- Audit 2026-06-08 fix: user_gitea_orgs (per-user Gitea org cache) was created
|
||||||
-- only in Repository.initSchema(); mirrored here (fresh path) and in migrate.ts
|
-- only in Repository.initSchema(); mirrored here (fresh path) and in migrate.ts
|
||||||
-- (upgrade path) so the task-list display SELECT never hits "no such table".
|
-- (upgrade path) so the task-list display SELECT never hits "no such table".
|
||||||
|
|||||||
@ -34,6 +34,36 @@ describe('calendar tools', () => {
|
|||||||
it('exposes TOOL_DEFS entries', () => {
|
it('exposes TOOL_DEFS entries', () => {
|
||||||
expect(TOOL_DEFS.AddCalendarEvent?.function.name).toBe('AddCalendarEvent');
|
expect(TOOL_DEFS.AddCalendarEvent?.function.name).toBe('AddCalendarEvent');
|
||||||
expect(TOOL_DEFS.ListCalendarEvents?.function.name).toBe('ListCalendarEvents');
|
expect(TOOL_DEFS.ListCalendarEvents?.function.name).toBe('ListCalendarEvents');
|
||||||
|
expect(TOOL_DEFS.CreateReminder?.function.name).toBe('CreateReminder');
|
||||||
|
expect(TOOL_DEFS.ListReminders?.function.name).toBe('ListReminders');
|
||||||
|
expect(TOOL_DEFS.CancelReminder?.function.name).toBe('CancelReminder');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates, lists, and cancels an owner-scoped relative reminder', async () => {
|
||||||
|
const created = await executeTool('CreateReminder', { body: '休憩する', delay_minutes: 20, title: '休憩' }, ctx());
|
||||||
|
expect(created?.isError).toBe(false);
|
||||||
|
const id = created!.output.match(/id=([^)]+)/)?.[1]!;
|
||||||
|
expect((await executeTool('ListReminders', {}, ctx()))?.output).toContain(id);
|
||||||
|
expect((await executeTool('CancelReminder', { id }, ctx()))?.isError).toBe(false);
|
||||||
|
expect((await executeTool('ListReminders', {}, ctx()))?.output).toContain('ありません');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects past or ambiguous reminder times', async () => {
|
||||||
|
expect((await executeTool('CreateReminder', { body: 'x', due_at: 'not-a-time' }, ctx()))?.isError).toBe(true);
|
||||||
|
expect((await executeTool('CreateReminder', { body: 'x', due_at: '2026-07-10T09:00' }, ctx()))?.isError).toBe(true);
|
||||||
|
expect((await executeTool('CreateReminder', { body: 'x', due_at: '2026-02-30T09:00:00Z' }, ctx()))?.isError).toBe(true);
|
||||||
|
expect((await executeTool('CreateReminder', { body: 'x', due_at: '2026-07-10T24:00Z' }, ctx()))?.isError).toBe(true);
|
||||||
|
expect((await executeTool('CreateReminder', { body: 'x', due_at: '2026-07-10T09:00+23:59' }, ctx()))?.isError).toBe(true);
|
||||||
|
expect((await executeTool('CreateReminder', { body: 'x', due_at: '2020-01-01T00:00:00.000Z' }, ctx()))?.isError).toBe(true);
|
||||||
|
expect((await executeTool('CreateReminder', { body: 'x', due_at: new Date(Date.now() + 60_000).toISOString(), delay_minutes: 1 }, ctx()))?.isError).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose or cancel another owner\'s reminder', async () => {
|
||||||
|
const created = await executeTool('CreateReminder', { body: 'private', delay_minutes: 10 }, ctx());
|
||||||
|
const id = created!.output.match(/id=([^)]+)/)?.[1]!;
|
||||||
|
expect((await executeTool('ListReminders', {}, ctx({ ownerId: 'u2' })))?.output).toContain('ありません');
|
||||||
|
expect((await executeTool('CancelReminder', { id }, ctx({ ownerId: 'u2' })))?.isError).toBe(true);
|
||||||
|
expect((await executeTool('ListReminders', {}, ctx()))?.output).toContain(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AddCalendarEvent happy path: row created with createdBy=agent + sourceTaskId', async () => {
|
it('AddCalendarEvent happy path: row created with createdBy=agent + sourceTaskId', async () => {
|
||||||
|
|||||||
@ -6,6 +6,14 @@ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|||||||
const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
|
const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
const MAX_TITLE_LEN = 200;
|
const MAX_TITLE_LEN = 200;
|
||||||
const MAX_DESC_LEN = 4000;
|
const MAX_DESC_LEN = 4000;
|
||||||
|
const MAX_REMINDER_BODY_LEN = 4000;
|
||||||
|
const ISO_WITH_TIMEZONE_PATTERN = /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,3})?)?(?:Z|[+-](?:(?:0\d|1[0-3]):[0-5]\d|14:00))$/;
|
||||||
|
|
||||||
|
function isValidIsoWithTimezone(value: string): boolean {
|
||||||
|
if (!ISO_WITH_TIMEZONE_PATTERN.test(value) || Number.isNaN(Date.parse(value))) return false;
|
||||||
|
const [year, month, day] = value.slice(0, 10).split('-').map(Number);
|
||||||
|
return day >= 1 && day <= new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||||
|
}
|
||||||
|
|
||||||
let _repo: Repository | null = null;
|
let _repo: Repository | null = null;
|
||||||
|
|
||||||
@ -51,9 +59,29 @@ const LIST_CALENDAR_EVENTS_DEF: ToolDef = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CREATE_REMINDER_DEF: ToolDef = {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'CreateReminder',
|
||||||
|
description: '指定時刻または指定分後に、実行ユーザー向けの通知を登録する。過去時刻や曖昧な時刻は登録しない。',
|
||||||
|
parameters: { type: 'object', properties: {
|
||||||
|
body: { type: 'string', description: '通知本文(必須)' },
|
||||||
|
due_at: { type: 'string', description: 'UTC ISO 8601 時刻。delay_minutes と排他' },
|
||||||
|
delay_minutes: { type: 'number', description: '現在からの分数。due_at と排他、1以上' },
|
||||||
|
title: { type: 'string', description: '任意のタイトル' }, image_url: { type: 'string', description: '任意の HTTPS 画像 URL' },
|
||||||
|
}, required: ['body'] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIST_REMINDERS_DEF: ToolDef = { type: 'function', function: { name: 'ListReminders', description: '実行ユーザーが未配信かつ取消していないリマインダーを一覧する。', parameters: { type: 'object', properties: {}, required: [] } } };
|
||||||
|
const CANCEL_REMINDER_DEF: ToolDef = { type: 'function', function: { name: 'CancelReminder', description: '実行ユーザーの未配信リマインダーを ID で取消する。', parameters: { type: 'object', properties: { id: { type: 'string', description: 'ListReminders で確認した ID' } }, required: ['id'] } } };
|
||||||
|
|
||||||
export const TOOL_DEFS: Record<string, ToolDef> = {
|
export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||||
AddCalendarEvent: ADD_CALENDAR_EVENT_DEF,
|
AddCalendarEvent: ADD_CALENDAR_EVENT_DEF,
|
||||||
ListCalendarEvents: LIST_CALENDAR_EVENTS_DEF,
|
ListCalendarEvents: LIST_CALENDAR_EVENTS_DEF,
|
||||||
|
CreateReminder: CREATE_REMINDER_DEF,
|
||||||
|
ListReminders: LIST_REMINDERS_DEF,
|
||||||
|
CancelReminder: CANCEL_REMINDER_DEF,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function executeTool(
|
export async function executeTool(
|
||||||
@ -63,9 +91,57 @@ export async function executeTool(
|
|||||||
): Promise<ToolResult | null> {
|
): Promise<ToolResult | null> {
|
||||||
if (name === 'AddCalendarEvent') return executeAddCalendarEvent(input, ctx);
|
if (name === 'AddCalendarEvent') return executeAddCalendarEvent(input, ctx);
|
||||||
if (name === 'ListCalendarEvents') return executeListCalendarEvents(input, ctx);
|
if (name === 'ListCalendarEvents') return executeListCalendarEvents(input, ctx);
|
||||||
|
if (name === 'CreateReminder') return executeCreateReminder(input, ctx);
|
||||||
|
if (name === 'ListReminders') return executeListReminders(ctx);
|
||||||
|
if (name === 'CancelReminder') return executeCancelReminder(input, ctx);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireReminderOwner(ctx: ToolContext): string | null {
|
||||||
|
return typeof ctx.ownerId === 'string' && ctx.ownerId.length > 0 ? ctx.ownerId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeCreateReminder(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||||
|
if (!_repo) return { output: 'Reminder repo is not initialized', isError: true };
|
||||||
|
const ownerId = requireReminderOwner(ctx);
|
||||||
|
if (!ownerId) return { output: 'リマインダーにはユーザー所有者が必要です', isError: true };
|
||||||
|
const body = input.body;
|
||||||
|
if (typeof body !== 'string' || !body.trim() || body.length > MAX_REMINDER_BODY_LEN) return { output: `body は1〜${MAX_REMINDER_BODY_LEN}文字で指定してください`, isError: true };
|
||||||
|
const title = input.title;
|
||||||
|
if (title !== undefined && (typeof title !== 'string' || title.length > MAX_TITLE_LEN)) return { output: `title は最大${MAX_TITLE_LEN}文字です`, isError: true };
|
||||||
|
const imageUrl = input.image_url;
|
||||||
|
if (imageUrl !== undefined && (typeof imageUrl !== 'string' || !imageUrl.startsWith('https://'))) return { output: 'image_url は HTTPS URL で指定してください', isError: true };
|
||||||
|
const hasDueAt = input.due_at !== undefined;
|
||||||
|
const hasDelay = input.delay_minutes !== undefined;
|
||||||
|
if (hasDueAt === hasDelay) return { output: 'due_at または delay_minutes のどちらか一方を指定してください', isError: true };
|
||||||
|
let due: Date;
|
||||||
|
if (hasDueAt) {
|
||||||
|
if (typeof input.due_at !== 'string' || !isValidIsoWithTimezone(input.due_at)) return { output: 'due_at は実在する、タイムゾーン付き ISO 8601 形式で指定してください', isError: true };
|
||||||
|
due = new Date(input.due_at);
|
||||||
|
} else {
|
||||||
|
if (typeof input.delay_minutes !== 'number' || !Number.isFinite(input.delay_minutes) || input.delay_minutes < 1) return { output: 'delay_minutes は1以上の分数で指定してください', isError: true };
|
||||||
|
due = new Date(Date.now() + input.delay_minutes * 60_000);
|
||||||
|
}
|
||||||
|
if (due.getTime() <= Date.now()) return { output: '過去の時刻には登録できません。明示的な未来時刻を指定してください', isError: true };
|
||||||
|
const reminder = _repo.createAgentReminder({ ownerId, spaceId: ctx.spaceId ?? null, title: typeof title === 'string' ? title : null, body: body.trim(), imageUrl: typeof imageUrl === 'string' ? imageUrl : null, dueAt: due.toISOString() });
|
||||||
|
return { output: `リマインダーを登録しました: ${reminder.dueAt} (id=${reminder.id})`, isError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeListReminders(ctx: ToolContext): Promise<ToolResult> {
|
||||||
|
if (!_repo) return { output: 'Reminder repo is not initialized', isError: true };
|
||||||
|
const ownerId = requireReminderOwner(ctx);
|
||||||
|
if (!ownerId) return { output: 'リマインダーにはユーザー所有者が必要です', isError: true };
|
||||||
|
const reminders = _repo.listAgentReminders(ownerId);
|
||||||
|
return { output: reminders.length ? reminders.map(r => `- ${r.dueAt} 「${r.title ?? r.body}」(id=${r.id})`).join('\n') : '未配信のリマインダーはありません', isError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeCancelReminder(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||||
|
if (!_repo) return { output: 'Reminder repo is not initialized', isError: true };
|
||||||
|
const ownerId = requireReminderOwner(ctx);
|
||||||
|
if (!ownerId || typeof input.id !== 'string' || !input.id) return { output: 'id とユーザー所有者が必要です', isError: true };
|
||||||
|
return _repo.cancelAgentReminder(input.id, ownerId) ? { output: 'リマインダーを取消しました', isError: false } : { output: '未配信のリマインダーが見つかりません', isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
async function executeAddCalendarEvent(
|
async function executeAddCalendarEvent(
|
||||||
input: Record<string, unknown>,
|
input: Record<string, unknown>,
|
||||||
ctx: ToolContext,
|
ctx: ToolContext,
|
||||||
|
|||||||
85
src/llm/effort-injection.test.ts
Normal file
85
src/llm/effort-injection.test.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildJobExtraBody, type EffortInjectionWorker } from './effort-injection.js';
|
||||||
|
|
||||||
|
describe('buildJobExtraBody', () => {
|
||||||
|
it('jobEffort null → returns worker.extraBody unchanged', () => {
|
||||||
|
const extraBody = { top_k: 20 };
|
||||||
|
const worker: EffortInjectionWorker = { extraBody, reasoningEfforts: ['low', 'max'] };
|
||||||
|
const result = buildJobExtraBody(worker, null);
|
||||||
|
expect(result).toEqual(extraBody);
|
||||||
|
expect(extraBody).toEqual({ top_k: 20 }); // not mutated
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jobEffort undefined → returns worker.extraBody unchanged', () => {
|
||||||
|
const extraBody = { top_k: 20 };
|
||||||
|
const worker: EffortInjectionWorker = { extraBody, reasoningEfforts: ['low', 'max'] };
|
||||||
|
const result = buildJobExtraBody(worker, undefined);
|
||||||
|
expect(result).toEqual(extraBody);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mode unset (default body) + effort in reasoningEfforts → injected at top level', () => {
|
||||||
|
const extraBody = { top_k: 20 };
|
||||||
|
const worker: EffortInjectionWorker = { extraBody, reasoningEfforts: ['low', 'max'] };
|
||||||
|
const result = buildJobExtraBody(worker, 'max');
|
||||||
|
expect(result).toEqual({ top_k: 20, reasoning_effort: 'max' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mode 'chat_template_kwargs' + effort in efforts → nested, preserving existing sub-keys", () => {
|
||||||
|
const extraBody = { top_k: 20, chat_template_kwargs: { enable_thinking: true } };
|
||||||
|
const worker: EffortInjectionWorker = {
|
||||||
|
extraBody,
|
||||||
|
reasoningEfforts: ['low', 'high'],
|
||||||
|
reasoningEffortMode: 'chat_template_kwargs',
|
||||||
|
};
|
||||||
|
const result = buildJobExtraBody(worker, 'high');
|
||||||
|
expect(result).toEqual({
|
||||||
|
top_k: 20,
|
||||||
|
chat_template_kwargs: { enable_thinking: true, reasoning_effort: 'high' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('effort NOT in reasoningEfforts → no injection, extraBody unchanged', () => {
|
||||||
|
const extraBody = { top_k: 20 };
|
||||||
|
const worker: EffortInjectionWorker = { extraBody, reasoningEfforts: ['low'] };
|
||||||
|
const result = buildJobExtraBody(worker, 'high');
|
||||||
|
expect(result).toEqual(extraBody);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reasoningEfforts undefined + effort set → no injection', () => {
|
||||||
|
const extraBody = { top_k: 20 };
|
||||||
|
const worker: EffortInjectionWorker = { extraBody };
|
||||||
|
const result = buildJobExtraBody(worker, 'high');
|
||||||
|
expect(result).toEqual(extraBody);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extraBody undefined + effort valid → injects into a fresh object', () => {
|
||||||
|
const worker: EffortInjectionWorker = { reasoningEfforts: ['max'] };
|
||||||
|
const result = buildJobExtraBody(worker, 'max');
|
||||||
|
expect(result).toEqual({ reasoning_effort: 'max' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chat_template_kwargs is a non-object → does not throw, replaced with fresh object', () => {
|
||||||
|
const extraBody = { chat_template_kwargs: 'oops' as unknown as Record<string, unknown> };
|
||||||
|
const worker: EffortInjectionWorker = {
|
||||||
|
extraBody,
|
||||||
|
reasoningEfforts: ['high'],
|
||||||
|
reasoningEffortMode: 'chat_template_kwargs',
|
||||||
|
};
|
||||||
|
let result: Record<string, unknown> | undefined;
|
||||||
|
expect(() => {
|
||||||
|
result = buildJobExtraBody(worker, 'high');
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(result).toEqual({ chat_template_kwargs: { reasoning_effort: 'high' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('input not mutated: frozen extraBody → returns a new object without throwing', () => {
|
||||||
|
const extraBody = Object.freeze({ top_k: 20 });
|
||||||
|
const worker: EffortInjectionWorker = { extraBody, reasoningEfforts: ['max'] };
|
||||||
|
let result: Record<string, unknown> | undefined;
|
||||||
|
expect(() => {
|
||||||
|
result = buildJobExtraBody(worker, 'max');
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(result).toEqual({ top_k: 20, reasoning_effort: 'max' });
|
||||||
|
expect(result).not.toBe(extraBody);
|
||||||
|
});
|
||||||
|
});
|
||||||
58
src/llm/effort-injection.ts
Normal file
58
src/llm/effort-injection.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
|
export interface EffortInjectionWorker {
|
||||||
|
extraBody?: Record<string, unknown>;
|
||||||
|
reasoningEfforts?: string[];
|
||||||
|
reasoningEffortMode?: 'body' | 'chat_template_kwargs';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overlay a job's requested reasoning effort onto the worker's extra_body.
|
||||||
|
*
|
||||||
|
* - jobEffort null/undefined → return worker.extraBody unchanged
|
||||||
|
* - effort not declared in worker.reasoningEfforts (or none declared) → do
|
||||||
|
* NOT inject; return worker.extraBody unchanged and log a warning
|
||||||
|
* (defensive: the enqueue path validates, so this only fires on a
|
||||||
|
* config-reload race)
|
||||||
|
* - mode 'body' (or unset) → { ...extraBody, reasoning_effort: jobEffort }
|
||||||
|
* - mode 'chat_template_kwargs' → nested under chat_template_kwargs,
|
||||||
|
* preserving any existing chat_template_kwargs sub-keys
|
||||||
|
*/
|
||||||
|
export function buildJobExtraBody(
|
||||||
|
worker: EffortInjectionWorker,
|
||||||
|
jobEffort: string | null | undefined,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
if (jobEffort === null || jobEffort === undefined) {
|
||||||
|
return worker.extraBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supported = worker.reasoningEfforts ?? [];
|
||||||
|
if (!supported.includes(jobEffort)) {
|
||||||
|
logger.warn(`[effort-injection] effort not supported by worker, skipping effort=${jobEffort}`);
|
||||||
|
return worker.extraBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraBody = worker.extraBody ?? {};
|
||||||
|
|
||||||
|
if (worker.reasoningEffortMode === 'chat_template_kwargs') {
|
||||||
|
const existingKwargs = isPlainObject(extraBody.chat_template_kwargs)
|
||||||
|
? extraBody.chat_template_kwargs
|
||||||
|
: {};
|
||||||
|
return {
|
||||||
|
...extraBody,
|
||||||
|
chat_template_kwargs: {
|
||||||
|
...existingKwargs,
|
||||||
|
reasoning_effort: jobEffort,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...extraBody,
|
||||||
|
reasoning_effort: jobEffort,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -193,4 +193,49 @@ describe('executeJob pre-flight gates (behaviour pinning)', () => {
|
|||||||
expect(updateJobCalls.some(([, patch]) => patch.status === 'waiting_human')).toBe(false);
|
expect(updateJobCalls.some(([, patch]) => patch.status === 'waiting_human')).toBe(false);
|
||||||
expect(repo.unlockIssue).toHaveBeenCalledWith('local/task-77', 77);
|
expect(repo.unlockIssue).toHaveBeenCalledWith('local/task-77', 77);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does NOT requeue a model-mismatched job when requiredWorkerId pins it to this worker (pin wins over piece.model)', async () => {
|
||||||
|
// Given: 同じ model-mismatch 条件(worker が exotic-model をサーブできない)だが、
|
||||||
|
// job が worker-1 に requiredWorkerId でピン留めされている。ピン留めされたジョブは
|
||||||
|
// worker-1 にしか claim されないため(Task 8 claim SQL)、ここで requeue すると
|
||||||
|
// claim → mismatch → requeue の無限ループになる。
|
||||||
|
//
|
||||||
|
// 実行経路は requeueOnModelMismatch の直後に startPieceRun(実 LLM 呼び出し込みの
|
||||||
|
// ReAct ループ)へ進んでしまい、そこまでモックするのは過剰なため、
|
||||||
|
// executeJob から切り出し済みの private ゲートメソッドを直接呼ぶ(既存の
|
||||||
|
// 「requeue する」ケースは executeJob 経由で pin-gate-piece 一式ごと検証済みなので、
|
||||||
|
// ここでは pin による早期リターンだけを焦点にする)。
|
||||||
|
const { repo, updateJobCalls } = makeRepoMock();
|
||||||
|
const worker = new Worker(
|
||||||
|
'worker-1',
|
||||||
|
'http://localhost:11434/v1',
|
||||||
|
'test-model',
|
||||||
|
repo as never,
|
||||||
|
makeConfig({ worktreeDir: '/tmp/worker-gate-pin-model' }),
|
||||||
|
);
|
||||||
|
(worker as unknown as { availableModels: Set<string> }).availableModels = new Set(['test-model']);
|
||||||
|
|
||||||
|
const piece = {
|
||||||
|
name: 'pin-gate-piece',
|
||||||
|
description: 'gate pinning test piece',
|
||||||
|
max_movements: 30,
|
||||||
|
initial_movement: 'respond',
|
||||||
|
model: 'exotic-model',
|
||||||
|
movements: [
|
||||||
|
{ name: 'respond', persona: 'assistant', instruction: 'answer', rules: [], default_next: 'COMPLETE' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// When: requeueOnModelMismatch を requiredWorkerId='worker-1' の job で直接呼ぶ
|
||||||
|
const requeued = await (
|
||||||
|
worker as unknown as {
|
||||||
|
requeueOnModelMismatch: (job: Job, piece: unknown) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
).requeueOnModelMismatch(makeLocalTaskJob({ requiredWorkerId: 'worker-1' }), piece);
|
||||||
|
|
||||||
|
// Then: 「requeue しない」(false) が返り、queued への書き戻し・監査ログのどちらも発生しない
|
||||||
|
expect(requeued).toBe(false);
|
||||||
|
expect(updateJobCalls.some(([, patch]) => patch.status === 'queued')).toBe(false);
|
||||||
|
expect(repo.addAuditLog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -111,4 +111,53 @@ describe('Worker LLM client construction — extraBody propagation', () => {
|
|||||||
const options = constructorSpy.mock.calls[0]?.[8];
|
const options = constructorSpy.mock.calls[0]?.[8];
|
||||||
expect(options?.extraBody).toBeUndefined();
|
expect(options?.extraBody).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('injects the job reasoning_effort into extraBody when the worker declares it as supported', async () => {
|
||||||
|
const { Worker } = await import('./worker.js');
|
||||||
|
const { LocalProgressReporter } = await import('./progress/local-reporter.js');
|
||||||
|
|
||||||
|
const extraBody = { top_k: 20 };
|
||||||
|
|
||||||
|
const config: AppConfig = {
|
||||||
|
provider: {
|
||||||
|
model: 'test-model',
|
||||||
|
workers: [
|
||||||
|
{
|
||||||
|
id: 'worker-1',
|
||||||
|
endpoint: 'http://localhost:11434/v1',
|
||||||
|
extraBody,
|
||||||
|
reasoningEfforts: ['low', 'max'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
worktreeDir: '/tmp/worker-extra-body-test',
|
||||||
|
concurrency: 1,
|
||||||
|
maxMovements: 30,
|
||||||
|
retry: { maxAttempts: 3, backoffSeconds: [60, 300, 900] },
|
||||||
|
ask: { maxPerJob: 2 },
|
||||||
|
subtasks: { maxDepth: 2 },
|
||||||
|
tools: {
|
||||||
|
searxngUrl: 'http://localhost:8080',
|
||||||
|
visionModel: 'vision',
|
||||||
|
visionTimeout: 60,
|
||||||
|
visionMaxTokens: 1024,
|
||||||
|
webfetchTimeout: 30,
|
||||||
|
websearchTimeout: 15,
|
||||||
|
webfetchAllowedHosts: [],
|
||||||
|
},
|
||||||
|
} as AppConfig;
|
||||||
|
|
||||||
|
const repo = {} as Repository;
|
||||||
|
const worker = new Worker('worker-1', 'http://localhost:11434/v1', 'test-model', repo, config);
|
||||||
|
|
||||||
|
const job = { id: 'job-1', requiredRole: 'auto', reasoningEffort: 'max' } as unknown as Job;
|
||||||
|
const piece = {} as PieceDef;
|
||||||
|
const reporter = {} as InstanceType<typeof LocalProgressReporter>;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(worker as any).createJobLlmClient(job, piece, reporter);
|
||||||
|
|
||||||
|
const options = constructorSpy.mock.calls[0]?.[8];
|
||||||
|
expect(options?.extraBody).toEqual({ top_k: 20, reasoning_effort: 'max' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -65,6 +65,8 @@ describe('inheritJobContext (derived-job session inheritance)', () => {
|
|||||||
visibilityScopeOrgId: 'org-9',
|
visibilityScopeOrgId: 'org-9',
|
||||||
spaceId: 'case-42',
|
spaceId: 'case-42',
|
||||||
browserSessionProfileId: null,
|
browserSessionProfileId: null,
|
||||||
|
requiredWorkerId: null,
|
||||||
|
reasoningEffort: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
72
src/worker.inherit-llm-selection.test.ts
Normal file
72
src/worker.inherit-llm-selection.test.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { Repository } from './db/repository.js';
|
||||||
|
import { inheritJobContext } from './worker.js';
|
||||||
|
|
||||||
|
// Task 12 (Phase 1 LLM 選択): a job pinned to a specific worker/reasoning
|
||||||
|
// effort (requiredWorkerId/reasoningEffort, added in Task 7) must propagate
|
||||||
|
// that pin to derived jobs — subtask spawn and subtask ASK re-enqueue both
|
||||||
|
// build their createJob params by spreading inheritJobContext(parent). Without
|
||||||
|
// this, "use the big model at max effort" would silently fall back to
|
||||||
|
// auto-routing inside subtasks.
|
||||||
|
describe('inheritJobContext (worker/reasoning-effort inheritance)', () => {
|
||||||
|
let dir: string;
|
||||||
|
let repo: Repository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'inherit-llm-'));
|
||||||
|
repo = new Repository(join(dir, 'db.sqlite'));
|
||||||
|
repo.getDb().prepare(
|
||||||
|
`INSERT INTO users (id, email, role, status, created_at, updated_at)
|
||||||
|
VALUES ('u-1', 'u1@example.com', 'user', 'active', 0, 0)`,
|
||||||
|
).run();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
repo.close?.();
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the parent job requiredWorkerId and reasoningEffort', async () => {
|
||||||
|
const parent = await repo.createJob({
|
||||||
|
repo: 'local/task-1', issueNumber: 1, instruction: 'x', pieceName: 'general',
|
||||||
|
ownerId: 'u-1', requiredWorkerId: 'big-worker', reasoningEffort: 'max',
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const inherited = inheritJobContext(parent);
|
||||||
|
expect(inherited.requiredWorkerId).toBe('big-worker');
|
||||||
|
expect(inherited.reasoningEffort).toBe('max');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for both when the parent has no pin', async () => {
|
||||||
|
const parent = await repo.createJob({
|
||||||
|
repo: 'local/task-2', issueNumber: 2, instruction: 'x', pieceName: 'general',
|
||||||
|
ownerId: 'u-1',
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const inherited = inheritJobContext(parent);
|
||||||
|
expect(inherited.requiredWorkerId).toBeNull();
|
||||||
|
expect(inherited.reasoningEffort).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mirrors worker.ts subtask spawn (~line 1515-1533): the derived job's
|
||||||
|
// createJob params spread inheritJobContext(parent) — assert the pin
|
||||||
|
// actually persists on the child row, not just on the intermediate object.
|
||||||
|
it('a derived job spreading inheritJobContext persists the worker/effort pin', async () => {
|
||||||
|
const parent = await repo.createJob({
|
||||||
|
repo: 'local/task-3', issueNumber: 3, instruction: 'parent', pieceName: 'general',
|
||||||
|
ownerId: 'u-1', requiredWorkerId: 'big-worker', reasoningEffort: 'max',
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const sub = await repo.createJob({
|
||||||
|
repo: `subtask/${parent.id}`, issueNumber: 0, instruction: 'child',
|
||||||
|
pieceName: 'general', parentJobId: parent.id, subtaskDepth: 1,
|
||||||
|
...inheritJobContext(parent),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const reloaded = await repo.getJob(sub.id);
|
||||||
|
expect(reloaded?.requiredWorkerId).toBe('big-worker');
|
||||||
|
expect(reloaded?.reasoningEffort).toBe('max');
|
||||||
|
});
|
||||||
|
});
|
||||||
166
src/worker.llm-selection-fixes.test.ts
Normal file
166
src/worker.llm-selection-fixes.test.ts
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import type { AppConfig } from './config.js';
|
||||||
|
import type { PieceDef } from './engine/piece-runner.js';
|
||||||
|
import type { Repository, Job } from './db/repository.js';
|
||||||
|
|
||||||
|
// codex round3 findings on the Phase 1 LLM-selection branch:
|
||||||
|
//
|
||||||
|
// Fix A (P1): resolveModel(piece) prioritized piece.model over the worker's
|
||||||
|
// own configured model even when the job was pinned to this worker
|
||||||
|
// (job.requiredWorkerId set). A pinned job must run on the pinned worker's
|
||||||
|
// model, not whatever model the piece happens to declare.
|
||||||
|
//
|
||||||
|
// Fix B (P2): answerSubtaskAsk built its OpenAICompatClient with the raw
|
||||||
|
// worker extraBody, never applying buildJobExtraBody(workerDef,
|
||||||
|
// subtaskJob.reasoningEffort) — so an effort-pinned task's auto-answered
|
||||||
|
// subtask ASK ran at default effort instead of the task's chosen effort.
|
||||||
|
|
||||||
|
const constructorSpy = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('./llm/openai-compat.js', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('./llm/openai-compat.js')>();
|
||||||
|
class SpyOpenAICompatClient extends actual.OpenAICompatClient {
|
||||||
|
constructor(...args: ConstructorParameters<typeof actual.OpenAICompatClient>) {
|
||||||
|
constructorSpy(...args);
|
||||||
|
super(...args);
|
||||||
|
}
|
||||||
|
async *chat() {
|
||||||
|
yield { type: 'text', text: 'ok' } as never;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...actual, OpenAICompatClient: SpyOpenAICompatClient };
|
||||||
|
});
|
||||||
|
|
||||||
|
function baseConfig(): AppConfig {
|
||||||
|
return {
|
||||||
|
provider: {
|
||||||
|
model: 'big-model',
|
||||||
|
workers: [
|
||||||
|
{
|
||||||
|
id: 'worker-1',
|
||||||
|
endpoint: 'http://localhost:11434/v1',
|
||||||
|
reasoningEfforts: ['low', 'max'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
worktreeDir: '/tmp/worker-llm-selection-fixes-test',
|
||||||
|
concurrency: 1,
|
||||||
|
maxMovements: 30,
|
||||||
|
retry: { maxAttempts: 3, backoffSeconds: [60, 300, 900] },
|
||||||
|
ask: { maxPerJob: 2 },
|
||||||
|
subtasks: { maxDepth: 2 },
|
||||||
|
tools: {
|
||||||
|
searxngUrl: 'http://localhost:8080',
|
||||||
|
visionModel: 'vision',
|
||||||
|
visionTimeout: 60,
|
||||||
|
visionMaxTokens: 1024,
|
||||||
|
webfetchTimeout: 30,
|
||||||
|
websearchTimeout: 15,
|
||||||
|
webfetchAllowedHosts: [],
|
||||||
|
},
|
||||||
|
} as AppConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Fix A (P1): pinned job runs on the pinned worker model, not piece.model', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
constructorSpy.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveModel ignores piece.model when pinned=true, even if the piece model is available', async () => {
|
||||||
|
const { Worker } = await import('./worker.js');
|
||||||
|
const repo = {} as Repository;
|
||||||
|
const worker = new Worker('worker-1', 'http://localhost:11434/v1', 'big-model', repo, baseConfig());
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(worker as any).availableModels = new Set(['big-model', 'small-model']);
|
||||||
|
|
||||||
|
const piece = { model: 'small-model' } as PieceDef;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const pinnedResult = (worker as any).resolveModel(piece, { pinned: true });
|
||||||
|
expect(pinnedResult).toBe('big-model');
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const unpinnedResult = (worker as any).resolveModel(piece, { pinned: false });
|
||||||
|
expect(unpinnedResult).toBe('small-model');
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const defaultResult = (worker as any).resolveModel(piece);
|
||||||
|
expect(defaultResult).toBe('small-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createJobLlmClient resolves the pinned worker model (not piece.model) end-to-end when job.requiredWorkerId is set', async () => {
|
||||||
|
const { Worker } = await import('./worker.js');
|
||||||
|
const { LocalProgressReporter } = await import('./progress/local-reporter.js');
|
||||||
|
const repo = {} as Repository;
|
||||||
|
const worker = new Worker('worker-1', 'http://localhost:11434/v1', 'big-model', repo, baseConfig());
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(worker as any).availableModels = new Set(['big-model', 'small-model']);
|
||||||
|
|
||||||
|
const piece = { model: 'small-model' } as PieceDef;
|
||||||
|
const reporter = {} as InstanceType<typeof LocalProgressReporter>;
|
||||||
|
|
||||||
|
const pinnedJob = { id: 'job-1', requiredRole: 'auto', requiredWorkerId: 'worker-1' } as unknown as Job;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(worker as any).createJobLlmClient(pinnedJob, piece, reporter);
|
||||||
|
expect(constructorSpy.mock.calls[0]?.[1]).toBe('big-model');
|
||||||
|
|
||||||
|
constructorSpy.mockClear();
|
||||||
|
const unpinnedJob = { id: 'job-2', requiredRole: 'auto', requiredWorkerId: null } as unknown as Job;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(worker as any).createJobLlmClient(unpinnedJob, piece, reporter);
|
||||||
|
expect(constructorSpy.mock.calls[0]?.[1]).toBe('small-model');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Fix B (P2): subtask ASK auto-answer applies the subtask job reasoning effort', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
constructorSpy.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answerSubtaskAsk injects reasoning_effort from the subtask job into extraBody', async () => {
|
||||||
|
const { Worker } = await import('./worker.js');
|
||||||
|
const config = baseConfig();
|
||||||
|
config.provider.workers![0].extraBody = { top_k: 20 };
|
||||||
|
const repo = {
|
||||||
|
getJob: vi.fn().mockResolvedValue({ id: 'parent-1', instruction: 'parent instruction', ownerId: 'u-1' }),
|
||||||
|
} as unknown as Repository;
|
||||||
|
const worker = new Worker('worker-1', 'http://localhost:11434/v1', 'big-model', repo, config);
|
||||||
|
|
||||||
|
const subtaskJob = {
|
||||||
|
id: 'subtask-1',
|
||||||
|
requiredRole: 'auto',
|
||||||
|
instruction: 'subtask instruction',
|
||||||
|
reasoningEffort: 'max',
|
||||||
|
} as unknown as Job;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await (worker as any).answerSubtaskAsk(subtaskJob, 'parent-1', 'need a decision');
|
||||||
|
|
||||||
|
expect(constructorSpy).toHaveBeenCalledTimes(1);
|
||||||
|
const options = constructorSpy.mock.calls[0]?.[8];
|
||||||
|
expect(options?.extraBody).toEqual({ top_k: 20, reasoning_effort: 'max' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves extraBody unchanged when the subtask job has no reasoning effort pin', async () => {
|
||||||
|
const { Worker } = await import('./worker.js');
|
||||||
|
const config = baseConfig();
|
||||||
|
config.provider.workers![0].extraBody = { top_k: 20 };
|
||||||
|
const repo = {
|
||||||
|
getJob: vi.fn().mockResolvedValue({ id: 'parent-1', instruction: 'parent instruction', ownerId: 'u-1' }),
|
||||||
|
} as unknown as Repository;
|
||||||
|
const worker = new Worker('worker-1', 'http://localhost:11434/v1', 'big-model', repo, config);
|
||||||
|
|
||||||
|
const subtaskJob = {
|
||||||
|
id: 'subtask-2',
|
||||||
|
requiredRole: 'auto',
|
||||||
|
instruction: 'subtask instruction',
|
||||||
|
reasoningEffort: null,
|
||||||
|
} as unknown as Job;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await (worker as any).answerSubtaskAsk(subtaskJob, 'parent-1', 'need a decision');
|
||||||
|
|
||||||
|
const options = constructorSpy.mock.calls[0]?.[8];
|
||||||
|
expect(options?.extraBody).toEqual({ top_k: 20 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -5,6 +5,7 @@ import { assertProfileOwner } from './engine/browser-session-auth.js';
|
|||||||
import { decryptProfileState } from './crypto/profile-dek.js';
|
import { decryptProfileState } from './crypto/profile-dek.js';
|
||||||
import { OpenAICompatClient } from './llm/openai-compat.js';
|
import { OpenAICompatClient } from './llm/openai-compat.js';
|
||||||
import { llmRoutingKey, shouldRequeueForModelMismatch } from './llm/routing-key.js';
|
import { llmRoutingKey, shouldRequeueForModelMismatch } from './llm/routing-key.js';
|
||||||
|
import { buildJobExtraBody } from './llm/effort-injection.js';
|
||||||
import { loadPiece, runPiece, PieceRunCallbacks, PieceDef, type PieceRunResult } from './engine/piece-runner.js';
|
import { loadPiece, runPiece, PieceRunCallbacks, PieceDef, type PieceRunResult } from './engine/piece-runner.js';
|
||||||
import { LocalProgressReporter } from './progress/local-reporter.js';
|
import { LocalProgressReporter } from './progress/local-reporter.js';
|
||||||
import { buildLocalConversationContext } from './engine/local-context.js';
|
import { buildLocalConversationContext } from './engine/local-context.js';
|
||||||
@ -241,6 +242,8 @@ export function inheritJobContext(job: Job): {
|
|||||||
visibilityScopeOrgId: string | null;
|
visibilityScopeOrgId: string | null;
|
||||||
spaceId: string | null;
|
spaceId: string | null;
|
||||||
browserSessionProfileId: number | null;
|
browserSessionProfileId: number | null;
|
||||||
|
requiredWorkerId: string | null;
|
||||||
|
reasoningEffort: string | null;
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
ownerId: job.ownerId,
|
ownerId: job.ownerId,
|
||||||
@ -248,6 +251,11 @@ export function inheritJobContext(job: Job): {
|
|||||||
visibilityScopeOrgId: job.visibilityScopeOrgId,
|
visibilityScopeOrgId: job.visibilityScopeOrgId,
|
||||||
spaceId: job.spaceId ?? null,
|
spaceId: job.spaceId ?? null,
|
||||||
browserSessionProfileId: job.browserSessionProfileId ?? null,
|
browserSessionProfileId: job.browserSessionProfileId ?? null,
|
||||||
|
// LLM 選択 Phase 1 (Task 12): 派生ジョブ(サブタスク spawn / ASK 再投入)は
|
||||||
|
// 親のワーカー pin / reasoning effort をそのまま引き継ぐ。null なら従来通り
|
||||||
|
// 自動ルーティング。
|
||||||
|
requiredWorkerId: job.requiredWorkerId,
|
||||||
|
reasoningEffort: job.reasoningEffort,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1015,7 +1023,7 @@ export class Worker {
|
|||||||
if (siblings && siblings.length > 1 && this.repo.peekNextClaimable) {
|
if (siblings && siblings.length > 1 && this.repo.peekNextClaimable) {
|
||||||
const peek = await this.repo.peekNextClaimable(this.workerId);
|
const peek = await this.repo.peekNextClaimable(this.workerId);
|
||||||
if (peek && peek.id !== this.lastYieldedJobId) {
|
if (peek && peek.id !== this.lastYieldedJobId) {
|
||||||
const idler = this.findIdlerCompetitor(peek.requiredRole);
|
const idler = peek.requiredWorkerId ? null : this.findIdlerCompetitor(peek.requiredRole);
|
||||||
if (idler) {
|
if (idler) {
|
||||||
this.lastYieldedJobId = peek.id;
|
this.lastYieldedJobId = peek.id;
|
||||||
idler.pokePoll();
|
idler.pokePoll();
|
||||||
@ -1099,7 +1107,7 @@ export class Worker {
|
|||||||
{
|
{
|
||||||
proxy: workerDefForAnswer.proxy === true,
|
proxy: workerDefForAnswer.proxy === true,
|
||||||
maxStreamMs: this.resolveMaxStreamMs(),
|
maxStreamMs: this.resolveMaxStreamMs(),
|
||||||
extraBody: workerDefForAnswer.extraBody,
|
extraBody: buildJobExtraBody(workerDefForAnswer, subtaskJob.reasoningEffort),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1411,6 +1419,13 @@ export class Worker {
|
|||||||
*/
|
*/
|
||||||
private async requeueOnModelMismatch(job: Job, piece: PieceDef): Promise<boolean> {
|
private async requeueOnModelMismatch(job: Job, piece: PieceDef): Promise<boolean> {
|
||||||
const jobId = job.id;
|
const jobId = job.id;
|
||||||
|
// A user-pinned job can only ever be claimed by this one worker, so
|
||||||
|
// requeue-on-model-mismatch would loop forever (claim → mismatch → requeue).
|
||||||
|
// The explicit worker pin wins over the piece's model hint: proceed with
|
||||||
|
// the pinned worker's model instead of requeuing.
|
||||||
|
if (job.requiredWorkerId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
shouldRequeueForModelMismatch({
|
shouldRequeueForModelMismatch({
|
||||||
isGateway: this.getWorkerDef().proxy === true,
|
isGateway: this.getWorkerDef().proxy === true,
|
||||||
@ -1481,7 +1496,7 @@ export class Worker {
|
|||||||
const resolvedModel = llmRoutingKey({
|
const resolvedModel = llmRoutingKey({
|
||||||
isGateway: isProxyWorker,
|
isGateway: isProxyWorker,
|
||||||
role: job.requiredRole,
|
role: job.requiredRole,
|
||||||
resolveDirectModel: () => this.resolveModel(piece),
|
resolveDirectModel: () => this.resolveModel(piece, { pinned: !!job.requiredWorkerId }),
|
||||||
});
|
});
|
||||||
const timeoutMs = (this.config.provider.timeoutMinutes ?? 10) * 60 * 1000;
|
const timeoutMs = (this.config.provider.timeoutMinutes ?? 10) * 60 * 1000;
|
||||||
const llmClient = new OpenAICompatClient(
|
const llmClient = new OpenAICompatClient(
|
||||||
@ -1497,7 +1512,7 @@ export class Worker {
|
|||||||
proxy: isProxyWorker,
|
proxy: isProxyWorker,
|
||||||
maxStreamMs: this.resolveMaxStreamMs(),
|
maxStreamMs: this.resolveMaxStreamMs(),
|
||||||
requestPromptProgress: workerDefForLlm.returnProgress === true,
|
requestPromptProgress: workerDefForLlm.returnProgress === true,
|
||||||
extraBody: workerDefForLlm.extraBody,
|
extraBody: buildJobExtraBody(workerDefForLlm, job.reasoningEffort),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return { llmClient, isProxyWorker };
|
return { llmClient, isProxyWorker };
|
||||||
@ -3146,8 +3161,8 @@ export class Worker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveModel(piece: PieceDef): string | undefined {
|
private resolveModel(piece: PieceDef, opts?: { pinned?: boolean }): string | undefined {
|
||||||
if (piece.model) {
|
if (piece.model && !opts?.pinned) {
|
||||||
if (this.availableModels.size === 0 || this.availableModels.has(piece.model)) {
|
if (this.availableModels.size === 0 || this.availableModels.has(piece.model)) {
|
||||||
return piece.model;
|
return piece.model;
|
||||||
}
|
}
|
||||||
@ -3170,6 +3185,27 @@ export class Worker {
|
|||||||
): Promise<'requeued_unhealthy' | 'retry' | 'failed'> {
|
): Promise<'requeued_unhealthy' | 'retry' | 'failed'> {
|
||||||
const { id: jobId, attempt, maxAttempts } = job;
|
const { id: jobId, attempt, maxAttempts } = job;
|
||||||
|
|
||||||
|
const recordRetry = async (data: {
|
||||||
|
disposition: 'retry' | 'requeued_unhealthy';
|
||||||
|
nextAttempt: number;
|
||||||
|
nextRetryAt: string | null;
|
||||||
|
}): Promise<void> => {
|
||||||
|
if (job.repo !== localTaskRepoName(job.issueNumber)) return;
|
||||||
|
const body = JSON.stringify({
|
||||||
|
type: 'job_retry',
|
||||||
|
disposition: data.disposition,
|
||||||
|
attempt,
|
||||||
|
nextAttempt: data.nextAttempt,
|
||||||
|
maxAttempts,
|
||||||
|
nextRetryAt: data.nextRetryAt,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await this.repo.addLocalTaskComment(job.issueNumber, 'system', body, 'progress');
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[worker:${this.workerId}] failed to record retry history for ${jobId}: ${err}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isLlmConnectionFatal = /connection error:\s*fetch failed|econnrefused|enotfound|etimedout|network error/i.test(errorMsg);
|
const isLlmConnectionFatal = /connection error:\s*fetch failed|econnrefused|enotfound|etimedout|network error/i.test(errorMsg);
|
||||||
if (isLlmConnectionFatal) {
|
if (isLlmConnectionFatal) {
|
||||||
this.healthy = false;
|
this.healthy = false;
|
||||||
@ -3195,6 +3231,11 @@ export class Worker {
|
|||||||
nextRetryAt: null,
|
nextRetryAt: null,
|
||||||
disposition: 'requeued_unhealthy',
|
disposition: 'requeued_unhealthy',
|
||||||
});
|
});
|
||||||
|
await recordRetry({
|
||||||
|
disposition: 'requeued_unhealthy',
|
||||||
|
nextAttempt: attempt,
|
||||||
|
nextRetryAt: null,
|
||||||
|
});
|
||||||
logger.warn(`[worker:${this.workerId}] job ${jobId} requeued after LLM connection error; worker marked unhealthy`);
|
logger.warn(`[worker:${this.workerId}] job ${jobId} requeued after LLM connection error; worker marked unhealthy`);
|
||||||
return 'requeued_unhealthy';
|
return 'requeued_unhealthy';
|
||||||
}
|
}
|
||||||
@ -3229,6 +3270,11 @@ export class Worker {
|
|||||||
nextRetryAt,
|
nextRetryAt,
|
||||||
disposition: 'retry',
|
disposition: 'retry',
|
||||||
});
|
});
|
||||||
|
await recordRetry({
|
||||||
|
disposition: 'retry',
|
||||||
|
nextAttempt: attempt + 1,
|
||||||
|
nextRetryAt,
|
||||||
|
});
|
||||||
logger.info(`[worker:${this.workerId}] job ${jobId} scheduled for retry ${attempt + 1}/${maxAttempts} at ${nextRetryAt}`);
|
logger.info(`[worker:${this.workerId}] job ${jobId} scheduled for retry ${attempt + 1}/${maxAttempts} at ${nextRetryAt}`);
|
||||||
return 'retry';
|
return 'retry';
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -4,8 +4,11 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useSetupState } from './hooks/useSetupState';
|
import { useSetupState } from './hooks/useSetupState';
|
||||||
import { SetupWizard } from './components/setup/SetupWizard';
|
import { SetupWizard } from './components/setup/SetupWizard';
|
||||||
import { createLocalTask, fetchLocalTask, type CreateLocalTaskInput, type Visibility } from './api';
|
import { createLocalTask, fetchLocalTask, type CreateLocalTaskInput, type Visibility } from './api';
|
||||||
|
import { claimSpaceCalendarReminders } from './api/calendar';
|
||||||
import { useUrlState } from './hooks/useUrlState';
|
import { useUrlState } from './hooks/useUrlState';
|
||||||
import { useToast } from './hooks/useToast';
|
import { useToast } from './hooks/useToast';
|
||||||
|
import { ToastHost } from './components/notifications/ToastHost';
|
||||||
|
import { ToastPet } from './components/notifications/ToastPet';
|
||||||
import { useLocalTaskList } from './hooks/useTaskList';
|
import { useLocalTaskList } from './hooks/useTaskList';
|
||||||
import { useBranding } from './hooks/useBranding';
|
import { useBranding } from './hooks/useBranding';
|
||||||
import { useLocalStorageState } from './hooks/useLocalStorageState';
|
import { useLocalStorageState } from './hooks/useLocalStorageState';
|
||||||
@ -182,11 +185,36 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
// 個人スペースを所有するので user=null でも従来通り解決する。
|
// 個人スペースを所有するので user=null でも従来通り解決する。
|
||||||
// 一覧が空/ローディング中なら personalSpaceId は null(graceful skeleton)。
|
// 一覧が空/ローディング中なら personalSpaceId は null(graceful skeleton)。
|
||||||
const { data: spaces } = useSpaces();
|
const { data: spaces } = useSpaces();
|
||||||
|
const { toasts, showToast, dismissToast } = useToast();
|
||||||
|
const [settingsSpaceId, setSettingsSpaceId] = useState<string | null>(null);
|
||||||
const personalSpaceId =
|
const personalSpaceId =
|
||||||
spaces?.find(
|
spaces?.find(
|
||||||
s => s.kind === 'personal' && (user == null || s.ownerId === user.id),
|
s => s.kind === 'personal' && (user == null || s.ownerId === user.id),
|
||||||
)?.id ?? null;
|
)?.id ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!spaces?.length) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const poll = async () => {
|
||||||
|
const events = await Promise.all(spaces.map(space => claimSpaceCalendarReminders(space.id).then(items => ({ space, items })).catch(() => null)));
|
||||||
|
if (cancelled) return;
|
||||||
|
for (const result of events) {
|
||||||
|
if (!result) continue;
|
||||||
|
for (const event of result.items) {
|
||||||
|
showToast(event.time ? `${event.date} ${event.time}` : event.date, 'info', {
|
||||||
|
id: `calendar-event-${event.id}`,
|
||||||
|
title: `予定: ${event.title}`,
|
||||||
|
actionLabel: 'カレンダーを開く',
|
||||||
|
onAction: () => setUrlState(prev => ({ ...prev, page: 'calendar', spaceId: result.space.id, spaceTaskId: undefined })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void poll();
|
||||||
|
const timer = window.setInterval(() => void poll(), 30_000);
|
||||||
|
return () => { cancelled = true; window.clearInterval(timer); };
|
||||||
|
}, [spaces, showToast, setUrlState]);
|
||||||
|
|
||||||
// Legacy `?page=tasks` deep links (the Tasks tab was removed in M2) are
|
// Legacy `?page=tasks` deep links (the Tasks tab was removed in M2) are
|
||||||
// normalized onto the workspace model once spaces have loaded. We wait for the
|
// normalized onto the workspace model once spaces have loaded. We wait for the
|
||||||
// personal workspace to resolve so the rewrite lands on the right rail, then
|
// personal workspace to resolve so the rewrite lands on the right rail, then
|
||||||
@ -298,9 +326,6 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
*/
|
*/
|
||||||
const [createInitialPiece, setCreateInitialPiece] = useState<string | null>(null);
|
const [createInitialPiece, setCreateInitialPiece] = useState<string | null>(null);
|
||||||
|
|
||||||
// Toast
|
|
||||||
const { toast, showToast } = useToast();
|
|
||||||
|
|
||||||
// URL sync. While a legacy Tasks deep link is still pending normalization,
|
// URL sync. While a legacy Tasks deep link is still pending normalization,
|
||||||
// skip the push: otherwise this would pushState the parsed fallback (page=tasks
|
// skip the push: otherwise this would pushState the parsed fallback (page=tasks
|
||||||
// → spaces) and pollute history *before* the legacy redirect's replaceState
|
// → spaces) and pollute history *before* the legacy redirect's replaceState
|
||||||
@ -450,6 +475,16 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
onNotificationClick: (taskId) => {
|
onNotificationClick: (taskId) => {
|
||||||
handleOpenTaskInSpace(taskId);
|
handleOpenTaskInSpace(taskId);
|
||||||
},
|
},
|
||||||
|
onInAppNotification: (notification) => {
|
||||||
|
const taskId = notification.data.taskId;
|
||||||
|
showToast(notification.body, notification.title.startsWith('❌') ? 'error' : 'info', {
|
||||||
|
id: notification.tag,
|
||||||
|
title: notification.title,
|
||||||
|
actionLabel: 'タスクを開く',
|
||||||
|
onAction: () => handleOpenTaskInSpace(taskId),
|
||||||
|
visual: notification.tag.endsWith('-succeeded') ? <ToastPet /> : undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// V2: SW posts `open-task` when the user clicks an OS notification and the
|
// V2: SW posts `open-task` when the user clicks an OS notification and the
|
||||||
@ -509,20 +544,10 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
onCompactChange={setCompactMode}
|
onCompactChange={setCompactMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div role="status" aria-live="polite" aria-atomic="true" className="flex-shrink-0">
|
<ToastHost toasts={toasts} onDismiss={dismissToast} />
|
||||||
{toast && (
|
|
||||||
<div className={
|
|
||||||
toast.variant === 'error'
|
|
||||||
? 'mx-4 mt-2 px-4 py-2.5 bg-red-50 dark:bg-red-500/15 border border-red-200 dark:border-red-500/30 rounded-xl text-[13px] text-red-800 dark:text-red-300'
|
|
||||||
: 'mx-4 mt-2 px-4 py-2.5 bg-green-50 dark:bg-green-500/15 border border-green-200 dark:border-green-500/30 rounded-xl text-[13px] text-green-800 dark:text-green-300'
|
|
||||||
}>
|
|
||||||
{toast.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{page === 'settings' && <div className="flex-1 min-h-0 overflow-hidden"><SettingsPage isAdmin={isAdmin} /></div>}
|
{page === 'settings' && <div className="flex-1 min-h-0 overflow-hidden"><SettingsPage isAdmin={isAdmin} urlState={urlState} setUrlState={setUrlState} workspaceName={spaces?.find(space => space.id === urlState.spaceId)?.title} onOpenWorkspaceSettings={urlState.spaceId ? () => { setSettingsSpaceId(urlState.spaceId!); setUrlState(prev => ({ ...prev, page: 'spaces', spaceTaskId: undefined })); } : undefined} /></div>}
|
||||||
{page === 'spaces' && <div className="flex-1 min-h-0 overflow-hidden"><SpacesPage spaceId={urlState.spaceId} spaceTaskId={urlState.spaceTaskId} chatFilter={{ search: urlState.spaceSearch, status: urlState.spaceStatus, sort: urlState.spaceSort, scope: urlState.spaceScope }} onChatFilterChange={(next) => setUrlState(prev => ({ ...prev, ...(next.search !== undefined ? { spaceSearch: next.search } : {}), ...(next.status !== undefined ? { spaceStatus: next.status } : {}), ...(next.sort !== undefined ? { spaceSort: next.sort } : {}), ...(next.scope !== undefined ? { spaceScope: next.scope } : {}) }))} onSelectSpace={(id) => setUrlState(prev => ({ ...prev, spaceId: id, spaceTaskId: undefined, spaceSearch: '', spaceStatus: 'all', spaceSort: 'updated', spaceScope: 'mine' }))} onSelectSpaceTask={(id) => setUrlState(prev => ({ ...prev, spaceTaskId: id > 0 ? id : undefined }))} onCreateTask={handleGlobalCreateTask} onOpenTask={handleOpenTaskInSpace} /></div>}
|
{page === 'spaces' && <div className="flex-1 min-h-0 overflow-hidden"><SpacesPage spaceId={urlState.spaceId} spaceTaskId={urlState.spaceTaskId} initialTab={settingsSpaceId === urlState.spaceId ? 'settings' : undefined} onInitialTabApplied={() => setSettingsSpaceId(null)} onOpenAppSettings={() => setUrlState(prev => ({ ...prev, page: 'settings', spaceTaskId: undefined }))} chatFilter={{ search: urlState.spaceSearch, status: urlState.spaceStatus, sort: urlState.spaceSort, scope: urlState.spaceScope }} onChatFilterChange={(next) => setUrlState(prev => ({ ...prev, ...(next.search !== undefined ? { spaceSearch: next.search } : {}), ...(next.status !== undefined ? { spaceStatus: next.status } : {}), ...(next.sort !== undefined ? { spaceSort: next.sort } : {}), ...(next.scope !== undefined ? { spaceScope: next.scope } : {}) }))} onSelectSpace={(id) => setUrlState(prev => ({ ...prev, spaceId: id, spaceTaskId: undefined, spaceSearch: '', spaceStatus: 'all', spaceSort: 'updated', spaceScope: 'mine' }))} onSelectSpaceTask={(id) => setUrlState(prev => ({ ...prev, spaceTaskId: id > 0 ? id : undefined }))} onCreateTask={handleGlobalCreateTask} onOpenTask={handleOpenTaskInSpace} /></div>}
|
||||||
{page === 'calendar' && <div className="flex-1 min-h-0 overflow-hidden"><CrossSpaceCalendar onOpenSpace={(id) => setUrlState(prev => ({ ...prev, page: 'spaces', spaceId: id, spaceTaskId: undefined }))} onOpenTask={handleOpenTaskInSpace} /></div>}
|
{page === 'calendar' && <div className="flex-1 min-h-0 overflow-hidden"><CrossSpaceCalendar onOpenSpace={(id) => setUrlState(prev => ({ ...prev, page: 'spaces', spaceId: id, spaceTaskId: undefined }))} onOpenTask={handleOpenTaskInSpace} /></div>}
|
||||||
{page === 'pieces' && <div className="flex-1 min-h-0 overflow-hidden"><PiecesPage showToast={showToast} isAdmin={isAdmin} /></div>}
|
{page === 'pieces' && <div className="flex-1 min-h-0 overflow-hidden"><PiecesPage showToast={showToast} isAdmin={isAdmin} /></div>}
|
||||||
{page === 'schedules' && <div className="flex-1 min-h-0 overflow-hidden"><SchedulesPage showToast={showToast} /></div>}
|
{page === 'schedules' && <div className="flex-1 min-h-0 overflow-hidden"><SchedulesPage showToast={showToast} /></div>}
|
||||||
@ -558,4 +583,3 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,3 +24,4 @@ export * from './api/skills';
|
|||||||
export * from './api/notifications';
|
export * from './api/notifications';
|
||||||
export * from './api/usage';
|
export * from './api/usage';
|
||||||
export * from './api/delegate';
|
export * from './api/delegate';
|
||||||
|
export * from './api/chat-connectors';
|
||||||
|
|||||||
@ -12,6 +12,8 @@ export interface CalendarEvent {
|
|||||||
endDate: string | null; // 終了日 YYYY-MM-DD。null = 単日
|
endDate: string | null; // 終了日 YYYY-MM-DD。null = 単日
|
||||||
time: string | null; // 開始 HH:MM。null = 終日
|
time: string | null; // 開始 HH:MM。null = 終日
|
||||||
endTime: string | null; // 終了 HH:MM。null = 終了時刻なし(time が null なら常に null)
|
endTime: string | null; // 終了 HH:MM。null = 終了時刻なし(time が null なら常に null)
|
||||||
|
reminderMinutes: number | null;
|
||||||
|
reminderDeliveredAt: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
createdBy: 'user' | 'agent';
|
createdBy: 'user' | 'agent';
|
||||||
@ -102,13 +104,13 @@ export async function fetchSpaceCalendarDay(
|
|||||||
|
|
||||||
export async function createCalendarEvent(
|
export async function createCalendarEvent(
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
input: { date: string; endDate?: string | null; time?: string | null; endTime?: string | null; title: string; description?: string | null },
|
input: { date: string; endDate?: string | null; time?: string | null; endTime?: string | null; reminderMinutes?: number | null; title: string; description?: string | null },
|
||||||
): Promise<CalendarEvent> {
|
): Promise<CalendarEvent> {
|
||||||
const { endDate, endTime, ...rest } = input;
|
const { endDate, endTime, reminderMinutes, ...rest } = input;
|
||||||
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events`, {
|
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ...rest, end_date: endDate ?? null, end_time: endTime ?? null }),
|
body: JSON.stringify({ ...rest, end_date: endDate ?? null, end_time: endTime ?? null, reminder_minutes: reminderMinutes ?? null }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data?.error ?? 'Failed to create event');
|
if (!res.ok) throw new Error(data?.error ?? 'Failed to create event');
|
||||||
@ -118,12 +120,13 @@ export async function createCalendarEvent(
|
|||||||
export async function updateCalendarEvent(
|
export async function updateCalendarEvent(
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
eventId: number,
|
eventId: number,
|
||||||
patch: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null },
|
patch: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; reminderMinutes?: number | null; title?: string; description?: string | null },
|
||||||
): Promise<CalendarEvent> {
|
): Promise<CalendarEvent> {
|
||||||
const { endDate, endTime, ...rest } = patch;
|
const { endDate, endTime, reminderMinutes, ...rest } = patch;
|
||||||
const body: Record<string, unknown> = { ...rest };
|
const body: Record<string, unknown> = { ...rest };
|
||||||
if (endDate !== undefined) body.end_date = endDate;
|
if (endDate !== undefined) body.end_date = endDate;
|
||||||
if (endTime !== undefined) body.end_time = endTime;
|
if (endTime !== undefined) body.end_time = endTime;
|
||||||
|
if (reminderMinutes !== undefined) body.reminder_minutes = reminderMinutes;
|
||||||
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events/${eventId}`, {
|
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events/${eventId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@ -143,3 +146,14 @@ export async function deleteCalendarEvent(spaceId: string, eventId: number): Pro
|
|||||||
throw new Error(err.error || res.statusText);
|
throw new Error(err.error || res.statusText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function claimSpaceCalendarReminders(spaceId: string): Promise<CalendarEvent[]> {
|
||||||
|
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/reminders/claim`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data?.error ?? 'Failed to claim calendar reminders');
|
||||||
|
return data.events as CalendarEvent[];
|
||||||
|
}
|
||||||
|
|||||||
117
ui/src/api/chat-connectors.ts
Normal file
117
ui/src/api/chat-connectors.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
// ui/src/api/chat-connectors.ts — admin CRUD for chat connector bindings
|
||||||
|
// (issue #801, Phase 2a). Talks to /api/admin/chat/bindings (requireAdmin)
|
||||||
|
// and, for the "which delegation backs this binding?" picker, the existing
|
||||||
|
// admin A2A delegations list at /api/admin/a2a/delegations.
|
||||||
|
//
|
||||||
|
// Mirrors the shape of ui/src/api/gateway.ts: plain fetch helpers, no
|
||||||
|
// react-query baked in (the form component owns useQuery/useMutation).
|
||||||
|
|
||||||
|
// --- Chat connector bindings ------------------------------------------------
|
||||||
|
|
||||||
|
export type ChatConnectorPlatform = 'slack';
|
||||||
|
export type ChatConnectorBindingStatus = 'active' | 'disabled';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors `toPublicBinding()` in src/bridge/chat/chat-connector-bindings-api.ts.
|
||||||
|
* Never includes bot credentials — those are write-only from the client's
|
||||||
|
* point of view.
|
||||||
|
*/
|
||||||
|
export interface ChatConnectorBinding {
|
||||||
|
id: string;
|
||||||
|
platform: ChatConnectorPlatform;
|
||||||
|
externalWorkspaceId: string;
|
||||||
|
externalChannelId: string;
|
||||||
|
spaceId: string;
|
||||||
|
a2aClientId: string;
|
||||||
|
a2aDelegationId: string;
|
||||||
|
status: ChatConnectorBindingStatus;
|
||||||
|
createdBy: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateChatConnectorBindingInput {
|
||||||
|
platform: ChatConnectorPlatform;
|
||||||
|
externalWorkspaceId: string;
|
||||||
|
externalChannelId: string;
|
||||||
|
a2aDelegationId: string;
|
||||||
|
botCredentials: { signingSecret: string; botToken: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateChatConnectorBindingInput {
|
||||||
|
status?: ChatConnectorBindingStatus;
|
||||||
|
botCredentials?: { signingSecret: string; botToken: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readErrorMessage(res: Response): Promise<string> {
|
||||||
|
const body = await res.json().catch(() => ({} as { error?: string }));
|
||||||
|
return (body as { error?: string }).error ?? res.statusText ?? String(res.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchChatConnectorBindings(): Promise<ChatConnectorBinding[]> {
|
||||||
|
const res = await fetch('/api/admin/chat/bindings');
|
||||||
|
if (!res.ok) throw new Error(await readErrorMessage(res));
|
||||||
|
const data = (await res.json()) as { bindings: ChatConnectorBinding[] };
|
||||||
|
return data.bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createChatConnectorBinding(
|
||||||
|
input: CreateChatConnectorBindingInput,
|
||||||
|
): Promise<ChatConnectorBinding> {
|
||||||
|
const res = await fetch('/api/admin/chat/bindings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await readErrorMessage(res));
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateChatConnectorBinding(
|
||||||
|
id: string,
|
||||||
|
patch: UpdateChatConnectorBindingInput,
|
||||||
|
): Promise<ChatConnectorBinding> {
|
||||||
|
const res = await fetch(`/api/admin/chat/bindings/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await readErrorMessage(res));
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteChatConnectorBinding(id: string): Promise<void> {
|
||||||
|
const res = await fetch(`/api/admin/chat/bindings/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error(await readErrorMessage(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- A2A delegations (admin, read-only) — used to populate the "which
|
||||||
|
// delegation backs this binding?" picker on the create form. A binding can
|
||||||
|
// only be backed by a delegation that is live AND grants exactly one space
|
||||||
|
// (chat-connector-bindings-api.ts enforces the same rule server-side; the
|
||||||
|
// client-side filter below just keeps invalid choices out of the picker). ---
|
||||||
|
|
||||||
|
export interface AdminA2aDelegation {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
clientId: string;
|
||||||
|
clientName: string;
|
||||||
|
grantedSpaceIds: string[];
|
||||||
|
grantedSkills: string[];
|
||||||
|
expiresAt: string | null;
|
||||||
|
revokedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
live: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminA2aDelegations(): Promise<AdminA2aDelegation[]> {
|
||||||
|
const res = await fetch('/api/admin/a2a/delegations');
|
||||||
|
if (!res.ok) throw new Error(await readErrorMessage(res));
|
||||||
|
const data = (await res.json()) as { delegations: AdminA2aDelegation[] };
|
||||||
|
return data.delegations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delegations eligible to back a new chat binding: live + exactly one granted space. */
|
||||||
|
export function eligibleDelegationsForChatBinding(delegations: AdminA2aDelegation[]): AdminA2aDelegation[] {
|
||||||
|
return delegations.filter(d => d.live && d.grantedSpaceIds.length === 1);
|
||||||
|
}
|
||||||
@ -52,6 +52,14 @@ export interface LocalTask {
|
|||||||
visibilityScopeOrgName?: string | null;
|
visibilityScopeOrgName?: string | null;
|
||||||
/** Spaces foundation: which space this task belongs to (null = legacy/個人). */
|
/** Spaces foundation: which space this task belongs to (null = legacy/個人). */
|
||||||
spaceId?: string | null;
|
spaceId?: string | null;
|
||||||
|
/**
|
||||||
|
* LLM 選択 Phase 1: sticky なワーカー直接指定。未設定 (null/undefined) は
|
||||||
|
* プロファイルベースの自動ルーティング。次にディスパッチされるジョブから
|
||||||
|
* 有効になる(実行中のジョブには影響しない)。
|
||||||
|
*/
|
||||||
|
llmWorkerId?: string | null;
|
||||||
|
/** llmWorkerId とセットの場合のみ有効な reasoning effort。 */
|
||||||
|
llmEffort?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
latestJob?: {
|
latestJob?: {
|
||||||
@ -211,6 +219,26 @@ export interface LocalTaskComment {
|
|||||||
injectedAt: string | null;
|
injectedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MovementHistoryEvent {
|
||||||
|
eventId: string;
|
||||||
|
ts: string;
|
||||||
|
seq: number;
|
||||||
|
line: number;
|
||||||
|
runId: string;
|
||||||
|
kind: 'movement_start' | 'movement_complete' | 'llm_call_retry';
|
||||||
|
movement: string | null;
|
||||||
|
iteration: number | null;
|
||||||
|
payload: {
|
||||||
|
next?: string | null;
|
||||||
|
waitReason?: string | null;
|
||||||
|
attempt?: number | null;
|
||||||
|
maxAttempts?: number | null;
|
||||||
|
errorClass?: string | null;
|
||||||
|
httpStatus?: number | null;
|
||||||
|
delayMs?: number | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateLocalTaskInput {
|
export interface CreateLocalTaskInput {
|
||||||
title?: string;
|
title?: string;
|
||||||
body: string;
|
body: string;
|
||||||
@ -230,6 +258,13 @@ export interface CreateLocalTaskInput {
|
|||||||
workspaceMode?: 'persistent' | 'ephemeral';
|
workspaceMode?: 'persistent' | 'ephemeral';
|
||||||
/** 紐付けるスペース。未指定なら owner の個人スペースに解決される。 */
|
/** 紐付けるスペース。未指定なら owner の個人スペースに解決される。 */
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
|
/**
|
||||||
|
* LLM 選択 Phase 1: タスクをピン留めする実行ワーカー。未指定/null は
|
||||||
|
* プロファイルルーティングに委ねる(従来どおり)。
|
||||||
|
*/
|
||||||
|
llmWorkerId?: string | null;
|
||||||
|
/** llmWorkerId とセットの場合のみ有効な reasoning effort。 */
|
||||||
|
llmEffort?: string | null;
|
||||||
options?: {
|
options?: {
|
||||||
mcpDisabled?: boolean;
|
mcpDisabled?: boolean;
|
||||||
skillsDisabled?: boolean;
|
skillsDisabled?: boolean;
|
||||||
@ -297,6 +332,13 @@ export async function fetchLocalTaskComments(taskId: number): Promise<LocalTaskC
|
|||||||
return data.comments ?? [];
|
return data.comments ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchMovementHistory(taskId: number): Promise<MovementHistoryEvent[]> {
|
||||||
|
const res = await fetch(`${BASE}/local/tasks/${taskId}/movement-history`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch movement history');
|
||||||
|
return data.events ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
export async function postLocalTaskComment(taskId: number, body: string, author: string = 'user', attachments?: Array<{ name: string; contentBase64: string }>): Promise<void> {
|
export async function postLocalTaskComment(taskId: number, body: string, author: string = 'user', attachments?: Array<{ name: string; contentBase64: string }>): Promise<void> {
|
||||||
const payload: Record<string, unknown> = { body, author };
|
const payload: Record<string, unknown> = { body, author };
|
||||||
if (attachments && attachments.length > 0) payload.attachments = attachments;
|
if (attachments && attachments.length > 0) payload.attachments = attachments;
|
||||||
@ -311,7 +353,20 @@ export async function postLocalTaskComment(taskId: number, body: string, author:
|
|||||||
|
|
||||||
export async function updateLocalTask(
|
export async function updateLocalTask(
|
||||||
taskId: number,
|
taskId: number,
|
||||||
updates: { title?: string; visibility?: Visibility; visibilityScopeOrgId?: string | null },
|
updates: {
|
||||||
|
title?: string;
|
||||||
|
visibility?: Visibility;
|
||||||
|
visibilityScopeOrgId?: string | null;
|
||||||
|
/**
|
||||||
|
* LLM 選択 Phase 1: sticky worker 直接指定。null で解除。effort を送るときは
|
||||||
|
* llmWorkerId も必ず一緒に送ること(サーバーは PATCH 済みフィールドだけ検証する
|
||||||
|
* ため、effort だけ null にすると「ワーカーは残っているのに effort だけ消える」
|
||||||
|
* のは OK だが、逆に worker を null にするときは effort も null で送らないと
|
||||||
|
* 「effort はワーカー指定とセットのみ」400 になり得る)。
|
||||||
|
*/
|
||||||
|
llmWorkerId?: string | null;
|
||||||
|
llmEffort?: string | null;
|
||||||
|
},
|
||||||
): Promise<LocalTask> {
|
): Promise<LocalTask> {
|
||||||
const res = await fetch(`${BASE}/local/tasks/${taskId}`, {
|
const res = await fetch(`${BASE}/local/tasks/${taskId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|||||||
@ -44,6 +44,32 @@ export async function fetchWorkerBackends(workerId: string): Promise<WorkerBacke
|
|||||||
return await res.json() as WorkerBackendsResponse;
|
return await res.json() as WorkerBackendsResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── LLM 選択 Phase 1: タスク作成時のワーカー直接指定 ──────────────────────────
|
||||||
|
|
||||||
|
export interface LlmWorkerListItem {
|
||||||
|
id: string;
|
||||||
|
model: string;
|
||||||
|
roles: string[];
|
||||||
|
reasoningEfforts?: string[];
|
||||||
|
vlm?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/llm/workers — タスクにピン留め可能な実行ワーカー一覧(title/reflection
|
||||||
|
* 専用ワーカーは含まない、サーバー側でフィルタ済み)。失敗時は空配列を返す。
|
||||||
|
*/
|
||||||
|
export async function fetchLlmWorkers(): Promise<LlmWorkerListItem[]> {
|
||||||
|
const res = await fetch('/api/llm/workers', { credentials: 'include' });
|
||||||
|
// Throw (not []) on a non-OK response so react-query surfaces an error
|
||||||
|
// state instead of a false "empty worker list". The composer's stalled
|
||||||
|
// badge is gated on `workersQuery.isSuccess`, so swallowing a transient
|
||||||
|
// 500/404 into [] made a healthy pinned worker look stalled.
|
||||||
|
if (!res.ok) throw new Error(`Failed to list LLM workers: ${res.status}`);
|
||||||
|
const data = await res.json() as { workers?: LlmWorkerListItem[] };
|
||||||
|
return data.workers ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
// ── Side Info Panel ────────────────────────────────────────────────────────
|
// ── Side Info Panel ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface NodeStatus {
|
export interface NodeStatus {
|
||||||
|
|||||||
18
ui/src/components/chat/ChatAttachmentList.tsx
Normal file
18
ui/src/components/chat/ChatAttachmentList.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
export interface ChatAttachment {
|
||||||
|
name: string;
|
||||||
|
contentBase64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatAttachmentList({ attachments, onRemove }: { attachments: ChatAttachment[]; onRemove: (name: string) => void }) {
|
||||||
|
if (attachments.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex max-h-12 min-w-0 flex-1 flex-wrap gap-1 overflow-y-auto">
|
||||||
|
{attachments.map(attachment => (
|
||||||
|
<span key={attachment.name} className="inline-flex items-center gap-1 rounded border border-hairline bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] text-slate-700">
|
||||||
|
{attachment.name}
|
||||||
|
<button type="button" onClick={() => onRemove(attachment.name)} aria-label={`${attachment.name}を削除`} className="ml-0.5 text-slate-400 hover:text-slate-700">×</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
ui/src/components/chat/ChatComposer.tsx
Normal file
172
ui/src/components/chat/ChatComposer.tsx
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type KeyboardEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { LocalTask } from '../../api';
|
||||||
|
import { useDraft } from '../../hooks/useDraft';
|
||||||
|
import { supportsFieldSizing, autosizeTextarea } from '../../lib/composerAutosize';
|
||||||
|
import { toBase64 } from '../../lib/fileAttachments';
|
||||||
|
import { ChatComposerPanel } from './ChatComposerPanel';
|
||||||
|
|
||||||
|
interface ChatComposerProps {
|
||||||
|
task: LocalTask;
|
||||||
|
commentsLength: number;
|
||||||
|
onSubmit: (body: string, attachments?: Array<{ name: string; contentBase64: string }>) => Promise<void>;
|
||||||
|
onCancel?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatComposer({ task, commentsLength, onSubmit, onCancel }: ChatComposerProps) {
|
||||||
|
const { t } = useTranslation('chat');
|
||||||
|
const { draft: restoredDraft, saveDraft, clearDraft } = useDraft(`chat:${task.id}`);
|
||||||
|
const [draft, setDraft] = useState(restoredDraft ?? '');
|
||||||
|
const [attachments, setAttachments] = useState<Array<{ name: string; contentBase64: string }>>([]);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [cancelling, setCancelling] = useState(false);
|
||||||
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
|
const [showPromptCoach, setShowPromptCoach] = useState(false);
|
||||||
|
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const needsAutosizeFallback = useMemo(() => !supportsFieldSizing(), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!needsAutosizeFallback) return;
|
||||||
|
const el = composerRef.current;
|
||||||
|
if (el) autosizeTextarea(el, 192);
|
||||||
|
}, [draft, needsAutosizeFallback]);
|
||||||
|
|
||||||
|
const submitBaselineRef = useRef<number | null>(null);
|
||||||
|
const submitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const jobStatus = task.latestJob?.status;
|
||||||
|
const isBusy = jobStatus === 'running' || jobStatus === 'dispatching' || jobStatus === 'waiting_subtasks';
|
||||||
|
const isPending = jobStatus === 'queued' || jobStatus === 'retry';
|
||||||
|
const hasActiveJob = isBusy || isPending;
|
||||||
|
const canInterject = jobStatus === 'running' || jobStatus === 'waiting_subtasks';
|
||||||
|
const inputLocked = jobStatus === 'dispatching';
|
||||||
|
const awaitingToolApproval =
|
||||||
|
jobStatus === 'waiting_human' && task.latestJob?.waitReason === 'tool_request';
|
||||||
|
const composerLocked = inputLocked || awaitingToolApproval;
|
||||||
|
|
||||||
|
const releaseSubmitting = () => {
|
||||||
|
if (submitTimeoutRef.current) {
|
||||||
|
clearTimeout(submitTimeoutRef.current);
|
||||||
|
submitTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
submitBaselineRef.current = null;
|
||||||
|
setSubmitting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!submitting) return;
|
||||||
|
const baseline = submitBaselineRef.current;
|
||||||
|
if (baseline === null) return;
|
||||||
|
if (commentsLength > baseline && hasActiveJob) {
|
||||||
|
releaseSubmitting();
|
||||||
|
}
|
||||||
|
}, [submitting, commentsLength, hasActiveJob]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (submitTimeoutRef.current) clearTimeout(submitTimeoutRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFiles = async (files: FileList | null) => {
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
const converted = await Promise.all(
|
||||||
|
Array.from(files).map(async f => ({ name: f.name, contentBase64: await toBase64(f) })),
|
||||||
|
);
|
||||||
|
setAttachments(prev => [...prev, ...converted]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAttachment = (name: string) => {
|
||||||
|
setAttachments(prev => prev.filter(a => a.name !== name));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if ((!draft.trim() && attachments.length === 0) || submitting) return;
|
||||||
|
setSendError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
submitBaselineRef.current = commentsLength;
|
||||||
|
try {
|
||||||
|
await onSubmit(draft, attachments.length > 0 ? attachments : undefined);
|
||||||
|
setDraft('');
|
||||||
|
setAttachments([]);
|
||||||
|
clearDraft();
|
||||||
|
if (submitTimeoutRef.current) clearTimeout(submitTimeoutRef.current);
|
||||||
|
submitTimeoutRef.current = setTimeout(releaseSubmitting, 10000);
|
||||||
|
} catch (e) {
|
||||||
|
setSendError(e instanceof Error && e.message ? e.message : t('pane.sendFailed'));
|
||||||
|
releaseSubmitting();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = async (e: ClipboardEvent) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
const files: File[] = [];
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
if (item.kind === 'file') {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) files.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (files.length === 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const converted = await Promise.all(
|
||||||
|
files.map(async f => {
|
||||||
|
const name = f.name === 'image.png' ? `paste-${Date.now()}.png` : f.name;
|
||||||
|
return { name, contentBase64: await toBase64(f) };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setAttachments(prev => [...prev, ...converted]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
if (!onCancel || cancelling) return;
|
||||||
|
setCancelling(true);
|
||||||
|
try {
|
||||||
|
await onCancel();
|
||||||
|
} finally {
|
||||||
|
setCancelling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatComposerPanel
|
||||||
|
task={task}
|
||||||
|
draft={draft}
|
||||||
|
attachments={attachments}
|
||||||
|
submitting={submitting}
|
||||||
|
cancelling={cancelling}
|
||||||
|
sendError={sendError}
|
||||||
|
isBusy={isBusy}
|
||||||
|
isPending={isPending}
|
||||||
|
canInterject={canInterject}
|
||||||
|
inputLocked={inputLocked}
|
||||||
|
awaitingToolApproval={awaitingToolApproval}
|
||||||
|
composerLocked={composerLocked}
|
||||||
|
hasActiveJob={hasActiveJob}
|
||||||
|
jobStatus={jobStatus}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={onCancel ? handleCancel : undefined}
|
||||||
|
onAttachClick={() => fileInputRef.current?.click()}
|
||||||
|
onRemoveAttachment={removeAttachment}
|
||||||
|
onFileChange={handleFiles}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onDraftChange={value => { setDraft(value); saveDraft(value); }}
|
||||||
|
showPromptCoach={showPromptCoach}
|
||||||
|
onTogglePromptCoach={() => setShowPromptCoach(value => !value)}
|
||||||
|
onApplyPromptRewrite={value => { setDraft(value); saveDraft(value); }}
|
||||||
|
onResend={handleSubmit}
|
||||||
|
composerRef={composerRef}
|
||||||
|
fileInputRef={fileInputRef}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
ui/src/components/chat/ChatComposerPanel.tsx
Normal file
226
ui/src/components/chat/ChatComposerPanel.tsx
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { ClipboardEvent, KeyboardEvent, RefObject } from 'react';
|
||||||
|
import type { LocalTask } from '../../api';
|
||||||
|
import { PromptCoachPanel } from '../create/PromptCoachPanel';
|
||||||
|
import { ToolRequestApproval } from './ToolRequestApproval';
|
||||||
|
import { PackageRequestApproval } from './PackageRequestApproval';
|
||||||
|
import { LlmSelectionControl } from './LlmSelectionControl';
|
||||||
|
import { ChatAttachmentList } from './ChatAttachmentList';
|
||||||
|
import { WaterContextGauge } from './WaterContextGauge';
|
||||||
|
|
||||||
|
interface ChatComposerPanelProps {
|
||||||
|
task: LocalTask;
|
||||||
|
draft: string;
|
||||||
|
attachments: Array<{ name: string; contentBase64: string }>;
|
||||||
|
submitting: boolean;
|
||||||
|
cancelling: boolean;
|
||||||
|
sendError: string | null;
|
||||||
|
isBusy: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
canInterject: boolean;
|
||||||
|
inputLocked: boolean;
|
||||||
|
awaitingToolApproval: boolean;
|
||||||
|
composerLocked: boolean;
|
||||||
|
hasActiveJob: boolean;
|
||||||
|
jobStatus: string | null | undefined;
|
||||||
|
onSubmit: () => Promise<void>;
|
||||||
|
onCancel?: () => Promise<void>;
|
||||||
|
onAttachClick: () => void;
|
||||||
|
onRemoveAttachment: (name: string) => void;
|
||||||
|
onFileChange: (files: FileList | null) => Promise<void>;
|
||||||
|
onPaste: (e: ClipboardEvent) => Promise<void>;
|
||||||
|
onKeyDown: (e: KeyboardEvent) => void;
|
||||||
|
onDraftChange: (value: string) => void;
|
||||||
|
showPromptCoach: boolean;
|
||||||
|
onTogglePromptCoach: () => void;
|
||||||
|
onApplyPromptRewrite: (value: string) => void;
|
||||||
|
onResend: () => Promise<void>;
|
||||||
|
composerRef: RefObject<HTMLTextAreaElement>;
|
||||||
|
fileInputRef: RefObject<HTMLInputElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatComposerPanel({
|
||||||
|
task,
|
||||||
|
draft,
|
||||||
|
attachments,
|
||||||
|
submitting,
|
||||||
|
cancelling,
|
||||||
|
sendError,
|
||||||
|
isBusy,
|
||||||
|
isPending,
|
||||||
|
canInterject,
|
||||||
|
inputLocked,
|
||||||
|
awaitingToolApproval,
|
||||||
|
composerLocked,
|
||||||
|
hasActiveJob,
|
||||||
|
jobStatus,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
onAttachClick,
|
||||||
|
onRemoveAttachment,
|
||||||
|
onFileChange,
|
||||||
|
onPaste,
|
||||||
|
onKeyDown,
|
||||||
|
onDraftChange,
|
||||||
|
showPromptCoach,
|
||||||
|
onTogglePromptCoach,
|
||||||
|
onApplyPromptRewrite,
|
||||||
|
onResend,
|
||||||
|
composerRef,
|
||||||
|
fileInputRef,
|
||||||
|
}: ChatComposerPanelProps) {
|
||||||
|
const { t } = useTranslation('chat');
|
||||||
|
return (
|
||||||
|
<div className="flex-shrink-0 border-t border-hairline bg-canvas p-2.5" style={{ paddingBottom: 'calc(10px + env(safe-area-inset-bottom, 0px))' }}>
|
||||||
|
{hasActiveJob && (
|
||||||
|
<div className={`flex items-center gap-2 mb-2 px-2.5 py-1 rounded-md text-2xs ${
|
||||||
|
canInterject
|
||||||
|
? 'bg-amber-50 dark:bg-amber-500/15 border border-amber-100 dark:border-amber-500/30 text-amber-700 dark:text-amber-300'
|
||||||
|
: isPending
|
||||||
|
? 'bg-surface-2 border border-hairline text-slate-600 dark:text-slate-300'
|
||||||
|
: 'bg-blue-50 dark:bg-blue-500/15 border border-blue-100 dark:border-blue-500/30 text-blue-700 dark:text-blue-300'
|
||||||
|
}`}>
|
||||||
|
<svg className="w-3 h-3 animate-spin flex-shrink-0" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||||
|
</svg>
|
||||||
|
<span>{canInterject ? t('pane.interjectHint') : isPending ? t('pane.queuedHint') : t('pane.agentRunningWait')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ToolRequestApproval taskId={task.id} poll={jobStatus === 'waiting_human' || isBusy} />
|
||||||
|
<PackageRequestApproval taskId={task.id} poll={jobStatus === 'waiting_human' || isBusy} />
|
||||||
|
{sendError && !isBusy && (
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-2 px-2.5 py-1 bg-red-50 dark:bg-red-500/15 border border-red-100 dark:border-red-500/30 rounded-md text-2xs text-red-700 dark:text-red-300">
|
||||||
|
<span className="truncate">⚠ {sendError}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onResend()}
|
||||||
|
disabled={submitting}
|
||||||
|
className="flex-shrink-0 px-2 h-6 bg-canvas border border-red-200 rounded text-[10px] font-medium text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-500/15 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('pane.resend')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`relative overflow-hidden rounded-xl border transition-shadow ${composerLocked ? 'border-hairline bg-surface' : 'border-hairline bg-canvas focus-within:border-accent focus-within:ring-2 focus-within:ring-accent-ring'}`}>
|
||||||
|
<WaterContextGauge
|
||||||
|
variant="fill"
|
||||||
|
promptTokens={task.latestJob?.contextPromptTokens}
|
||||||
|
limitTokens={task.latestJob?.contextLimitTokens}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
ref={composerRef}
|
||||||
|
value={draft}
|
||||||
|
onChange={e => onDraftChange(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onPaste={e => void onPaste(e)}
|
||||||
|
rows={1}
|
||||||
|
disabled={composerLocked}
|
||||||
|
placeholder={awaitingToolApproval ? t('toolRequest.composerLocked') : inputLocked ? t('pane.placeholder.dispatching') : canInterject ? t('pane.placeholder.interject') : isPending ? t('pane.placeholder.queued') : t('pane.placeholder.default')}
|
||||||
|
className="relative z-10 block w-full resize-none border-0 bg-transparent px-3 pt-2.5 pb-1 text-sm leading-6 text-slate-900 outline-none [field-sizing:content] min-h-6 max-h-48 overflow-y-auto disabled:text-slate-400 disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 flex flex-wrap items-center gap-1.5 px-2 pb-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => { void onFileChange(e.target.files); e.target.value = ''; }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={onAttachClick}
|
||||||
|
disabled={composerLocked || submitting}
|
||||||
|
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
|
||||||
|
title={t('pane.attachFile')}
|
||||||
|
aria-label={t('pane.attachFile')}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTogglePromptCoach}
|
||||||
|
disabled={composerLocked || !draft.trim()}
|
||||||
|
aria-expanded={showPromptCoach}
|
||||||
|
title={t('pane.evaluatePrompt')}
|
||||||
|
aria-label={t('pane.evaluatePrompt')}
|
||||||
|
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-surface hover:text-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="m12 3 1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3Z" />
|
||||||
|
<path d="m19 15 .75 2.25L22 18l-2.25.75L19 21l-.75-2.25L16 18l2.25-.75L19 15Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<ChatAttachmentList attachments={attachments} onRemove={onRemoveAttachment} />
|
||||||
|
<LlmSelectionControl task={task} busy={isBusy} />
|
||||||
|
<div className="ml-auto flex flex-shrink-0 items-center gap-2">
|
||||||
|
<WaterContextGauge
|
||||||
|
variant="label"
|
||||||
|
promptTokens={task.latestJob?.contextPromptTokens}
|
||||||
|
limitTokens={task.latestJob?.contextLimitTokens}
|
||||||
|
/>
|
||||||
|
{isBusy && onCancel ? (
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{canInterject && (
|
||||||
|
<button
|
||||||
|
disabled={submitting || (!draft.trim() && attachments.length === 0)}
|
||||||
|
onClick={onSubmit}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-amber-100 text-amber-800 border border-amber-300 hover:bg-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30 dark:hover:bg-amber-500/25 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<polyline points="15 10 20 15 15 20" />
|
||||||
|
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
|
||||||
|
</svg>
|
||||||
|
{t('pane.interject')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
disabled={cancelling}
|
||||||
|
onClick={() => void onCancel()}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-canvas border border-red-200 text-red-700 hover:bg-red-50 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/15 disabled:opacity-50"
|
||||||
|
title={t('pane.stopAgent')}
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<rect x="6" y="6" width="12" height="12" rx="2.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
{cancelling ? t('pane.stopping') : t('pane.stop')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : isPending ? (
|
||||||
|
<button
|
||||||
|
disabled={submitting || (!draft.trim() && attachments.length === 0)}
|
||||||
|
onClick={onSubmit}
|
||||||
|
title={t('pane.addToQueuedHint')}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-amber-100 text-amber-800 border border-amber-300 hover:bg-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30 dark:hover:bg-amber-500/25 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<polyline points="15 10 20 15 15 20" />
|
||||||
|
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
|
||||||
|
</svg>
|
||||||
|
{t('pane.addToQueued')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
disabled={submitting || composerLocked || (!draft.trim() && attachments.length === 0)}
|
||||||
|
onClick={onSubmit}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 h-9 bg-accent text-accent-fg rounded-md text-xs font-semibold disabled:opacity-50 hover:bg-accent-deep flex-shrink-0 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M22 2 11 13" />
|
||||||
|
<path d="M22 2 15 22 11 13 2 9 22 2Z" />
|
||||||
|
</svg>
|
||||||
|
{t('pane.send')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showPromptCoach && (
|
||||||
|
<div className="relative z-10 border-t border-hairline bg-canvas/90 px-3 py-2 backdrop-blur-sm" data-testid="chat-prompt-coach">
|
||||||
|
<PromptCoachPanel body={draft} piece={task.pieceName} onApplyRewrite={onApplyPromptRewrite} compact />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import { LocalTaskComment, getLocalFileRawUrl } from '../../api';
|
|||||||
import { MarkdownPreview } from '../files/FilePreview';
|
import { MarkdownPreview } from '../files/FilePreview';
|
||||||
import { MarkdownText } from '../../lib/markdown-text';
|
import { MarkdownText } from '../../lib/markdown-text';
|
||||||
import { ToolCallsSection, parseToolCallComment } from './ToolCallsSection';
|
import { ToolCallsSection, parseToolCallComment } from './ToolCallsSection';
|
||||||
|
import { parseJobRetry } from './movementHistory';
|
||||||
|
|
||||||
// We delegate spacing to MarkdownText's built-in COMPACT_PROSE default
|
// We delegate spacing to MarkdownText's built-in COMPACT_PROSE default
|
||||||
// (4px-ish paragraph margins, leading-snug, `!important` to beat the
|
// (4px-ish paragraph margins, leading-snug, `!important` to beat the
|
||||||
@ -283,6 +284,13 @@ function ProgressPill({ icon, children, variant = 'inline' }: { icon: React.Reac
|
|||||||
|
|
||||||
function ProgressCard({ comment, isStaleThinking }: { comment: LocalTaskComment; isStaleThinking?: boolean }) {
|
function ProgressCard({ comment, isStaleThinking }: { comment: LocalTaskComment; isStaleThinking?: boolean }) {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
|
const retry = parseJobRetry(comment.body);
|
||||||
|
if (retry) {
|
||||||
|
const text = retry.disposition === 'requeued_unhealthy'
|
||||||
|
? t('movementMap.workerRetry')
|
||||||
|
: t('movementMap.jobRetry', { attempt: retry.nextAttempt, max: retry.maxAttempts });
|
||||||
|
return <ProgressPill icon={<span className="text-amber-600">{'↻'}</span>}>{text}</ProgressPill>;
|
||||||
|
}
|
||||||
// Interjection ack → minimal centered confirmation
|
// Interjection ack → minimal centered confirmation
|
||||||
const ackData = tryParseInterjectionAck(comment.body);
|
const ackData = tryParseInterjectionAck(comment.body);
|
||||||
if (ackData) {
|
if (ackData) {
|
||||||
|
|||||||
120
ui/src/components/chat/ChatMessageFeed.scroll.test.tsx
Normal file
120
ui/src/components/chat/ChatMessageFeed.scroll.test.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import '../../test/dom-setup';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, fireEvent, screen } from '@testing-library/react';
|
||||||
|
import { renderWithProviders } from '../../test/render-helpers';
|
||||||
|
import type { LocalTask, LocalTaskComment } from '../../api';
|
||||||
|
import type { JobStreamState } from '../../hooks/useJobStream';
|
||||||
|
import { ChatMessageFeed } from './ChatMessageFeed';
|
||||||
|
|
||||||
|
const task = { id: 42, title: 'Task', pieceName: 'chat' } as LocalTask;
|
||||||
|
const jobStream = {
|
||||||
|
promptProgress: null,
|
||||||
|
streamingText: '',
|
||||||
|
toolCallStream: {},
|
||||||
|
connected: false,
|
||||||
|
delegateStreams: {},
|
||||||
|
llmState: null,
|
||||||
|
} as unknown as JobStreamState;
|
||||||
|
|
||||||
|
const comment = (id: number): LocalTaskComment => ({
|
||||||
|
id,
|
||||||
|
taskId: 42,
|
||||||
|
author: 'agent',
|
||||||
|
kind: 'comment',
|
||||||
|
body: `message ${id}`,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
injectedAt: null,
|
||||||
|
} as LocalTaskComment);
|
||||||
|
|
||||||
|
describe('ChatMessageFeed scroll-to-latest integration', () => {
|
||||||
|
it('keeps the positioner outside the scrolling element', () => {
|
||||||
|
renderWithProviders(<ChatMessageFeed task={task} comments={[comment(1)]} jobStream={jobStream} />);
|
||||||
|
const scroll = screen.getByTestId('chat-message-scroll');
|
||||||
|
Object.defineProperties(scroll, {
|
||||||
|
scrollHeight: { configurable: true, value: 1000 },
|
||||||
|
clientHeight: { configurable: true, value: 300 },
|
||||||
|
scrollTop: { configurable: true, writable: true, value: 100 },
|
||||||
|
});
|
||||||
|
fireEvent.scroll(scroll);
|
||||||
|
|
||||||
|
const positioner = screen.getByTestId('scroll-to-latest-positioner');
|
||||||
|
expect(scroll.contains(positioner)).toBe(false);
|
||||||
|
expect(positioner.parentElement).toBe(scroll.parentElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears a stale new-message count when manually reaching the bottom', () => {
|
||||||
|
const rendered = renderWithProviders(<ChatMessageFeed task={task} comments={[comment(1)]} jobStream={jobStream} />);
|
||||||
|
const scroll = screen.getByTestId('chat-message-scroll');
|
||||||
|
Object.defineProperties(scroll, {
|
||||||
|
scrollHeight: { configurable: true, value: 1000 },
|
||||||
|
clientHeight: { configurable: true, value: 300 },
|
||||||
|
scrollTop: { configurable: true, writable: true, value: 100 },
|
||||||
|
});
|
||||||
|
fireEvent.scroll(scroll);
|
||||||
|
rendered.rerender(<ChatMessageFeed task={task} comments={[comment(1), comment(2)]} jobStream={jobStream} />);
|
||||||
|
expect(screen.getByTestId('scroll-to-latest-positioner')).toHaveTextContent(/1/);
|
||||||
|
|
||||||
|
act(() => { scroll.scrollTop = 700; });
|
||||||
|
fireEvent.scroll(scroll);
|
||||||
|
expect(screen.queryByTestId('scroll-to-latest-positioner')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
act(() => { scroll.scrollTop = 100; });
|
||||||
|
fireEvent.scroll(scroll);
|
||||||
|
expect(screen.getByTestId('scroll-to-latest-positioner')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('scroll-to-latest-positioner')).not.toHaveTextContent(/1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels a pending auto-scroll when the user scrolls away', () => {
|
||||||
|
let pendingFrame: FrameRequestCallback | null = null;
|
||||||
|
const cancelFrame = vi.fn();
|
||||||
|
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => {
|
||||||
|
pendingFrame = callback;
|
||||||
|
return 17;
|
||||||
|
}));
|
||||||
|
vi.stubGlobal('cancelAnimationFrame', cancelFrame);
|
||||||
|
|
||||||
|
const rendered = renderWithProviders(<ChatMessageFeed task={task} comments={[comment(1)]} jobStream={jobStream} />);
|
||||||
|
const scroll = screen.getByTestId('chat-message-scroll');
|
||||||
|
Object.defineProperties(scroll, {
|
||||||
|
scrollHeight: { configurable: true, value: 1000 },
|
||||||
|
clientHeight: { configurable: true, value: 300 },
|
||||||
|
scrollTop: { configurable: true, writable: true, value: 700 },
|
||||||
|
});
|
||||||
|
fireEvent.scroll(scroll);
|
||||||
|
|
||||||
|
rendered.rerender(<ChatMessageFeed task={task} comments={[comment(1), comment(2)]} jobStream={jobStream} />);
|
||||||
|
expect(pendingFrame).not.toBeNull();
|
||||||
|
|
||||||
|
act(() => { scroll.scrollTop = 100; });
|
||||||
|
fireEvent.scroll(scroll);
|
||||||
|
expect(cancelFrame).toHaveBeenCalledWith(17);
|
||||||
|
|
||||||
|
act(() => { pendingFrame?.(0); });
|
||||||
|
expect(scroll.scrollTop).toBe(100);
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps only one pending auto-scroll across consecutive updates', () => {
|
||||||
|
let nextFrameId = 20;
|
||||||
|
const cancelFrame = vi.fn();
|
||||||
|
vi.stubGlobal('requestAnimationFrame', vi.fn(() => ++nextFrameId));
|
||||||
|
vi.stubGlobal('cancelAnimationFrame', cancelFrame);
|
||||||
|
|
||||||
|
const rendered = renderWithProviders(<ChatMessageFeed task={task} comments={[comment(1)]} jobStream={jobStream} />);
|
||||||
|
const scroll = screen.getByTestId('chat-message-scroll');
|
||||||
|
Object.defineProperties(scroll, {
|
||||||
|
scrollHeight: { configurable: true, value: 1000 },
|
||||||
|
clientHeight: { configurable: true, value: 300 },
|
||||||
|
scrollTop: { configurable: true, writable: true, value: 700 },
|
||||||
|
});
|
||||||
|
|
||||||
|
rendered.rerender(<ChatMessageFeed task={task} comments={[comment(1), comment(2)]} jobStream={jobStream} />);
|
||||||
|
rendered.rerender(<ChatMessageFeed task={task} comments={[comment(1), comment(2), comment(3)]} jobStream={jobStream} />);
|
||||||
|
expect(cancelFrame).toHaveBeenCalledWith(21);
|
||||||
|
|
||||||
|
rendered.unmount();
|
||||||
|
expect(cancelFrame).toHaveBeenCalledWith(22);
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
});
|
||||||
198
ui/src/components/chat/ChatMessageFeed.tsx
Normal file
198
ui/src/components/chat/ChatMessageFeed.tsx
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { LocalTask, LocalTaskComment, MovementHistoryEvent } from '../../api';
|
||||||
|
import type { JobStreamState } from '../../hooks/useJobStream';
|
||||||
|
import { ChatMessage } from './ChatMessage';
|
||||||
|
import { isThinkingComment, hasTrailingThinking } from './thinkingUtils';
|
||||||
|
import { groupCommentsByMovement, MovementGroupExpanded } from './MovementGroup';
|
||||||
|
import { SubtaskInlineCard } from './SubtaskInlineCard';
|
||||||
|
import { ChatMessageFeedPanel } from './ChatMessageFeedPanel';
|
||||||
|
import { assignRetryEvents } from './movementHistory';
|
||||||
|
import { ScrollToLatestButton } from './ScrollToLatestButton';
|
||||||
|
|
||||||
|
interface ChatMessageFeedProps {
|
||||||
|
task: LocalTask;
|
||||||
|
comments: LocalTaskComment[];
|
||||||
|
jobStream: JobStreamState;
|
||||||
|
historyEvents?: MovementHistoryEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessageFeed({ task, comments, jobStream, historyEvents = [] }: ChatMessageFeedProps) {
|
||||||
|
const { t } = useTranslation('chat');
|
||||||
|
const { promptProgress, streamingText, toolCallStream, connected, delegateStreams, llmState } = jobStream;
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isAtBottomRef = useRef(true);
|
||||||
|
const autoScrollFrameRef = useRef<number | null>(null);
|
||||||
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
|
const [newMessageCount, setNewMessageCount] = useState(0);
|
||||||
|
const prevCommentCountRef = useRef(comments.length);
|
||||||
|
const jobStatus = task.latestJob?.status;
|
||||||
|
const isBusy = jobStatus === 'running' || jobStatus === 'dispatching' || jobStatus === 'waiting_subtasks';
|
||||||
|
const isWaitingSubtasks = jobStatus === 'waiting_subtasks';
|
||||||
|
|
||||||
|
const checkIfAtBottom = useMemo(() => {
|
||||||
|
return () => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return true;
|
||||||
|
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollToBottom = useMemo(() => {
|
||||||
|
return () => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
isAtBottomRef.current = true;
|
||||||
|
setIsAtBottom(true);
|
||||||
|
setNewMessageCount(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const handler = () => {
|
||||||
|
const atBottom = checkIfAtBottom();
|
||||||
|
isAtBottomRef.current = atBottom;
|
||||||
|
if (!atBottom && autoScrollFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(autoScrollFrameRef.current);
|
||||||
|
autoScrollFrameRef.current = null;
|
||||||
|
}
|
||||||
|
setIsAtBottom(atBottom);
|
||||||
|
if (atBottom) setNewMessageCount(0);
|
||||||
|
};
|
||||||
|
el.addEventListener('scroll', handler, { passive: true });
|
||||||
|
return () => el.removeEventListener('scroll', handler);
|
||||||
|
}, [checkIfAtBottom]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const delta = comments.length - prevCommentCountRef.current;
|
||||||
|
prevCommentCountRef.current = comments.length;
|
||||||
|
if (delta <= 0) return;
|
||||||
|
if (isAtBottom) {
|
||||||
|
if (autoScrollFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(autoScrollFrameRef.current);
|
||||||
|
}
|
||||||
|
autoScrollFrameRef.current = requestAnimationFrame(() => {
|
||||||
|
autoScrollFrameRef.current = null;
|
||||||
|
if (isAtBottomRef.current && scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setNewMessageCount(prev => prev + delta);
|
||||||
|
}
|
||||||
|
}, [comments.length, isAtBottom]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (autoScrollFrameRef.current !== null) cancelAnimationFrame(autoScrollFrameRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const visibleComments = useMemo(() => {
|
||||||
|
if (!isBusy) return comments;
|
||||||
|
if (!connected) return comments;
|
||||||
|
if (!hasTrailingThinking(comments)) return comments;
|
||||||
|
return comments.slice(0, -1);
|
||||||
|
}, [comments, isBusy, connected]);
|
||||||
|
|
||||||
|
const groupedItems = useMemo(() => groupCommentsByMovement(visibleComments), [visibleComments]);
|
||||||
|
const retryAssignments = useMemo(() => assignRetryEvents(
|
||||||
|
historyEvents,
|
||||||
|
groupedItems
|
||||||
|
.filter((item): item is Extract<typeof item, { type: 'movement' }> => item.type === 'movement')
|
||||||
|
.map(item => ({ id: item.completionComment.id, movement: item.movementName })),
|
||||||
|
), [historyEvents, groupedItems]);
|
||||||
|
const animatingIdx = isBusy && hasTrailingThinking(visibleComments) ? visibleComments.length - 1 : -1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 relative min-h-0 overflow-x-hidden">
|
||||||
|
<div ref={scrollRef} data-testid="chat-message-scroll" className="absolute inset-0 overflow-y-auto overflow-x-hidden p-4">
|
||||||
|
<div className="max-w-3xl mx-auto min-w-0 flex flex-col gap-3">
|
||||||
|
{comments.length === 0 && (
|
||||||
|
<div className="text-center text-slate-400 text-[13px] py-8">
|
||||||
|
{t('pane.empty')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
let commentIdx = 0;
|
||||||
|
return groupedItems.map((item, gi) => {
|
||||||
|
if (item.type === 'movement') {
|
||||||
|
const startIdx = commentIdx;
|
||||||
|
commentIdx += item.inner.length;
|
||||||
|
return (
|
||||||
|
<MovementGroupExpanded
|
||||||
|
key={`mg-${gi}`}
|
||||||
|
item={item}
|
||||||
|
taskId={task.id}
|
||||||
|
isLast={gi === groupedItems.length - 1}
|
||||||
|
isRunning={isBusy}
|
||||||
|
animatingIdx={animatingIdx}
|
||||||
|
startIdx={startIdx}
|
||||||
|
historyEvents={retryAssignments.byCompletionId.get(item.completionComment.id) ?? []}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (item.type === 'diagnostic') {
|
||||||
|
commentIdx++;
|
||||||
|
return (
|
||||||
|
<div id={`comment-${item.comment.id}`} key={`diagnostic-${item.comment.id}`} tabIndex={-1} className="rounded outline-none transition-[background-color,box-shadow]">
|
||||||
|
<ChatMessage comment={item.comment} taskId={task.id} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const idx = commentIdx;
|
||||||
|
commentIdx++;
|
||||||
|
return (
|
||||||
|
<div id={`comment-${item.comment.id}`} key={item.comment.id} tabIndex={-1} className="rounded outline-none transition-[background-color,box-shadow]">
|
||||||
|
<ChatMessage
|
||||||
|
comment={item.comment}
|
||||||
|
taskId={task.id}
|
||||||
|
isStaleThinking={isThinkingComment(item.comment) && idx !== animatingIdx}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{retryAssignments.unassigned.map(event => (
|
||||||
|
<div
|
||||||
|
key={event.eventId}
|
||||||
|
id={`trace-event-${event.eventId}`}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="rounded border border-amber-200/70 bg-amber-50/70 px-2 py-1 text-[11px] text-amber-800 outline-none dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
{t('movementMap.llmRetry', { attempt: event.payload.attempt ?? '?', max: event.payload.maxAttempts ?? '?' })}
|
||||||
|
{event.movement ? ` · ${event.movement}` : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isWaitingSubtasks && task.subtasks && task.subtasks.length > 0 && (
|
||||||
|
<SubtaskInlineCard
|
||||||
|
subtasks={task.subtasks}
|
||||||
|
subtaskCount={task.subtaskCount ?? task.subtasks.length}
|
||||||
|
subtaskCompleted={task.subtaskCompleted ?? 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ChatMessageFeedPanel
|
||||||
|
jobStream={jobStream}
|
||||||
|
isBusy={isBusy}
|
||||||
|
isWaitingSubtasks={isWaitingSubtasks}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isAtBottom && (
|
||||||
|
<ScrollToLatestButton
|
||||||
|
newMessageCount={newMessageCount}
|
||||||
|
onClick={scrollToBottom}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
ui/src/components/chat/ChatMessageFeedPanel.tsx
Normal file
92
ui/src/components/chat/ChatMessageFeedPanel.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { JobStreamState } from '../../hooks/useJobStream';
|
||||||
|
import RotatingTips from './RotatingTips';
|
||||||
|
import { DelegateLiveConsole } from './DelegateLiveConsole';
|
||||||
|
import { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract';
|
||||||
|
|
||||||
|
interface ChatMessageFeedPanelProps {
|
||||||
|
jobStream: JobStreamState;
|
||||||
|
isBusy: boolean;
|
||||||
|
isWaitingSubtasks: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessageFeedPanel({
|
||||||
|
jobStream,
|
||||||
|
isBusy,
|
||||||
|
isWaitingSubtasks,
|
||||||
|
}: ChatMessageFeedPanelProps) {
|
||||||
|
const { t } = useTranslation('chat');
|
||||||
|
const { promptProgress, streamingText, toolCallStream, delegateStreams, llmState } = jobStream;
|
||||||
|
const liveToolContent = useMemo(() => {
|
||||||
|
const entries = Object.values(toolCallStream).filter(e => e.name in CONTENT_FIELD);
|
||||||
|
for (let k = entries.length - 1; k >= 0; k--) {
|
||||||
|
const text = extractStreamingField(entries[k].name, entries[k].rawArgs);
|
||||||
|
if (text) return { name: entries[k].name, text };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [toolCallStream]);
|
||||||
|
const liveToolRef = useRef<HTMLPreElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (liveToolRef.current) liveToolRef.current.scrollTop = liveToolRef.current.scrollHeight;
|
||||||
|
}, [liveToolContent?.text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isBusy && !isWaitingSubtasks && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
{promptProgress ? (
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-surface border border-hairline rounded-md text-2xs text-slate-600 min-w-[180px]">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span>{t('pane.processing')}</span>
|
||||||
|
<span className="font-mono tabular-nums">{promptProgress.percent}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-slate-200 rounded-full h-1">
|
||||||
|
<div
|
||||||
|
className="bg-emerald-500 h-1 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${promptProgress.percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : streamingText ? (
|
||||||
|
<div className="max-w-[80%] min-w-0 px-3 py-2 bg-canvas border border-hairline rounded-lg text-[13px] text-slate-800 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] opacity-70">
|
||||||
|
{streamingText}
|
||||||
|
<span className="inline-block w-0.5 h-3.5 bg-slate-400 animate-pulse ml-0.5 align-text-bottom" />
|
||||||
|
</div>
|
||||||
|
) : liveToolContent ? (
|
||||||
|
<div className="max-w-[80%] min-w-0 w-full px-3 py-2 bg-slate-50 border border-hairline rounded-lg">
|
||||||
|
<div className="text-2xs text-slate-500 mb-1 font-mono">{t('pane.generating', { name: liveToolContent.name })}</div>
|
||||||
|
<pre ref={liveToolRef} className="max-h-64 overflow-auto text-[12px] text-slate-800 whitespace-pre-wrap break-words [overflow-wrap:anywhere] m-0">
|
||||||
|
{liveToolContent.text}
|
||||||
|
<span className="inline-block w-0.5 h-3.5 bg-slate-400 animate-pulse ml-0.5 align-text-bottom" />
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="inline-flex items-center gap-2 px-2.5 py-1 bg-surface border border-hairline rounded-md text-2xs text-slate-600">
|
||||||
|
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||||
|
</svg>
|
||||||
|
{llmState?.phase === 'thinking' ? t('pane.llmThinking', { chars: (llmState.chars ?? 0).toLocaleString() })
|
||||||
|
: llmState?.phase === 'retrying' ? t('pane.llmRetrying', { attempt: llmState.attempt ?? 0, max: llmState.maxAttempts ?? 0, reason: llmState.reason ?? llmState.errorClass ?? '' })
|
||||||
|
: llmState?.phase === 'recovering' ? t('pane.llmRecovering', { stage: llmState.stage ?? '' })
|
||||||
|
: llmState?.phase === 'waiting' ? t('pane.llmWaiting')
|
||||||
|
: t('pane.agentResponding')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isBusy && Object.keys(delegateStreams).length > 0 && (
|
||||||
|
<div className="flex justify-start mt-1.5">
|
||||||
|
<DelegateLiveConsole streams={delegateStreams} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isBusy && <RotatingTips />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,7 +6,7 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { renderWithProviders } from '../../test/render-helpers';
|
import { renderWithProviders } from '../../test/render-helpers';
|
||||||
import { ChatPane } from './ChatPane';
|
import { ChatPane } from './ChatPane';
|
||||||
import { __resetDraftPruneForTest } from '../../hooks/useDraft';
|
import { __resetDraftPruneForTest } from '../../hooks/useDraft';
|
||||||
import type { LocalTask } from '../../api';
|
import type { LocalTask, LocalTaskComment } from '../../api';
|
||||||
|
|
||||||
const DRAFT_KEY = 'maestro:draft:v1:chat:42';
|
const DRAFT_KEY = 'maestro:draft:v1:chat:42';
|
||||||
|
|
||||||
@ -71,4 +71,56 @@ describe('ChatPane drafts', () => {
|
|||||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||||
expect(localStorage.getItem(DRAFT_KEY)).toBeNull();
|
expect(localStorage.getItem(DRAFT_KEY)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('queued のままでもコメントが反映されたら送信ロックを外す', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSubmit = vi.fn(async () => {});
|
||||||
|
const queuedTask = {
|
||||||
|
...task,
|
||||||
|
latestJob: { status: 'queued' },
|
||||||
|
} as LocalTask;
|
||||||
|
const rendered = renderWithProviders(<ChatPane task={queuedTask} comments={[]} onSubmit={onSubmit} />);
|
||||||
|
const textbox = screen.getByRole('textbox');
|
||||||
|
await user.click(textbox);
|
||||||
|
await user.type(textbox, '追記');
|
||||||
|
|
||||||
|
const queuedButton = screen.getByRole('button', { name: /Add to task|追加/ });
|
||||||
|
await user.click(queuedButton);
|
||||||
|
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('追記', undefined));
|
||||||
|
expect(queuedButton).toBeDisabled();
|
||||||
|
|
||||||
|
rendered.rerender(
|
||||||
|
<ChatPane
|
||||||
|
task={queuedTask}
|
||||||
|
comments={[
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
taskId: 42,
|
||||||
|
author: 'agent',
|
||||||
|
kind: 'comment',
|
||||||
|
body: '追記',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
injectedAt: null,
|
||||||
|
} as LocalTaskComment,
|
||||||
|
]}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const textboxAfter = screen.getByRole('textbox');
|
||||||
|
await user.click(textboxAfter);
|
||||||
|
await user.type(textboxAfter, '続き');
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: /Add to task|追加/ })).toBeEnabled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('入力中のプロンプトコーチを常設ボタンから開閉できる', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<ChatPane task={task} comments={[]} onSubmit={vi.fn(async () => {})} />);
|
||||||
|
await user.type(screen.getByRole('textbox'), '評価してほしい依頼');
|
||||||
|
const coachButton = screen.getByRole('button', { name: /Evaluate prompt|プロンプトを評価/i });
|
||||||
|
await user.click(coachButton);
|
||||||
|
expect(screen.getByTestId('chat-prompt-coach')).toBeInTheDocument();
|
||||||
|
await user.click(coachButton);
|
||||||
|
expect(screen.queryByTestId('chat-prompt-coach')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
296
ui/src/components/chat/ChatPane.llm-selection.test.tsx
Normal file
296
ui/src/components/chat/ChatPane.llm-selection.test.tsx
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import '../../test/dom-setup';
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { renderWithProviders } from '../../test/render-helpers';
|
||||||
|
import { ChatPane } from './ChatPane';
|
||||||
|
import type { LocalTask } from '../../api';
|
||||||
|
|
||||||
|
// useJobStream が EventSource を張るため jsdom にスタブを入れる(ChatPane.drafts.test.tsx と同じ)
|
||||||
|
class FakeEventSource {
|
||||||
|
onmessage: ((e: MessageEvent) => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
addEventListener() {}
|
||||||
|
removeEventListener() {}
|
||||||
|
close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORKERS = [
|
||||||
|
{ id: 'w1', model: 'm1', roles: ['auto'], reasoningEfforts: ['low', 'high'], vlm: false, enabled: true },
|
||||||
|
{ id: 'w2', model: 'm2', roles: ['auto'], reasoningEfforts: [], vlm: false, enabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
let patchCalls: Array<{ url: string; body: unknown }> = [];
|
||||||
|
|
||||||
|
function baseTask(overrides: Partial<LocalTask> = {}): LocalTask {
|
||||||
|
return {
|
||||||
|
id: 42,
|
||||||
|
title: 'テストタスク',
|
||||||
|
pieceName: 'chat',
|
||||||
|
llmWorkerId: null,
|
||||||
|
llmEffort: null,
|
||||||
|
latestJob: undefined,
|
||||||
|
...overrides,
|
||||||
|
} as unknown as LocalTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubFetch() {
|
||||||
|
patchCalls = [];
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes('/api/llm/workers')) {
|
||||||
|
return new Response(JSON.stringify({ workers: WORKERS }), { status: 200 });
|
||||||
|
}
|
||||||
|
if (url.includes('/api/local/tasks/42') && init?.method === 'PATCH') {
|
||||||
|
const body = init.body ? JSON.parse(init.body as string) : {};
|
||||||
|
patchCalls.push({ url, body });
|
||||||
|
return new Response(JSON.stringify({ task: baseTask(body as Partial<LocalTask>) }), { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response('{}', { status: 404 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('EventSource', FakeEventSource);
|
||||||
|
vi.stubGlobal('matchMedia', (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
}));
|
||||||
|
stubFetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openSelector(user: ReturnType<typeof userEvent.setup>) {
|
||||||
|
await user.click(screen.getByTestId('llm-select-trigger'));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChatPane — LLM 選択セレクター(コンポーザー)', () => {
|
||||||
|
it('ポップオーバーをbodyへPortalし、コンポーザーのoverflowで切り取らない', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(
|
||||||
|
<ChatPane task={baseTask()} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||||
|
);
|
||||||
|
await openSelector(user);
|
||||||
|
const popover = await screen.findByTestId('llm-select-popover');
|
||||||
|
const trigger = screen.getByTestId('llm-select-trigger');
|
||||||
|
expect(popover.parentElement).toBe(document.body);
|
||||||
|
expect(trigger.parentElement?.contains(popover)).toBe(false);
|
||||||
|
expect(popover).toHaveClass('fixed', 'overflow-y-auto', 'z-[80]');
|
||||||
|
expect(Number.parseFloat(popover.style.width)).toBeGreaterThan(0);
|
||||||
|
expect(popover).toHaveAttribute('role', 'dialog');
|
||||||
|
expect(trigger).toHaveAttribute('aria-haspopup', 'dialog');
|
||||||
|
expect(trigger).toHaveAttribute('aria-controls', 'llm-select-popover');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Portal内クリックでは閉じず、Escapeで閉じてトリガーへフォーカスを戻す', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(
|
||||||
|
<ChatPane task={baseTask()} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||||
|
);
|
||||||
|
await openSelector(user);
|
||||||
|
await user.click(await screen.findByTestId('llm-select-worker'));
|
||||||
|
expect(screen.getByTestId('llm-select-popover')).toBeInTheDocument();
|
||||||
|
await user.keyboard('{Escape}');
|
||||||
|
expect(screen.queryByTestId('llm-select-popover')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('llm-select-trigger')).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('開いたパネルへフォーカスを移し、Tab境界で閉じてコンポーザーへ戻る', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(
|
||||||
|
<ChatPane task={baseTask({ llmWorkerId: 'w1' })} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||||
|
);
|
||||||
|
await openSelector(user);
|
||||||
|
const worker = await screen.findByTestId('llm-select-worker');
|
||||||
|
await waitFor(() => expect(worker).toHaveFocus());
|
||||||
|
await user.tab({ shift: true });
|
||||||
|
expect(screen.queryByTestId('llm-select-popover')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('llm-select-trigger')).toHaveFocus();
|
||||||
|
|
||||||
|
await openSelector(user);
|
||||||
|
const reopenedWorker = await screen.findByTestId('llm-select-worker');
|
||||||
|
const reopenedEffort = screen.getByTestId('llm-select-effort');
|
||||||
|
await waitFor(() => expect(reopenedWorker).toHaveFocus());
|
||||||
|
await user.tab();
|
||||||
|
expect(reopenedEffort).toHaveFocus();
|
||||||
|
await user.tab();
|
||||||
|
expect(screen.queryByTestId('llm-select-popover')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('llm-select-trigger')).not.toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('操作要素が1個だけでもTabでポップオーバーから抜けられる', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(
|
||||||
|
<ChatPane task={baseTask()} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||||
|
);
|
||||||
|
await openSelector(user);
|
||||||
|
const worker = await screen.findByTestId('llm-select-worker');
|
||||||
|
await waitFor(() => expect(worker).toHaveFocus());
|
||||||
|
await user.tab();
|
||||||
|
expect(screen.queryByTestId('llm-select-popover')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('llm-select-trigger')).not.toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sticky 未設定なら「自動」表示、設定済みなら model @ id + effort を表示する', async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<ChatPane task={baseTask()} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('llm-select-trigger')).toHaveTextContent(/モデル: 自動|Model: auto/);
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ChatPane task={baseTask({ llmWorkerId: 'w1', llmEffort: 'high' })} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByTestId('llm-select-trigger')[1]).toHaveTextContent('m1 @ w1');
|
||||||
|
});
|
||||||
|
expect(screen.getAllByTestId('llm-select-trigger')[1]).toHaveTextContent('high');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ワーカーを選ぶと PATCH が {llmWorkerId, llmEffort:null} で発火する', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(
|
||||||
|
<ChatPane task={baseTask()} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||||
|
);
|
||||||
|
await openSelector(user);
|
||||||
|
const workerSelect = await screen.findByTestId('llm-select-worker');
|
||||||
|
await waitFor(() => expect(screen.getByText('m1 @ w1')).toBeInTheDocument());
|
||||||
|
await user.selectOptions(workerSelect, 'w1');
|
||||||
|
|
||||||
|
await waitFor(() => expect(patchCalls.length).toBe(1));
|
||||||
|
expect(patchCalls[0].body).toEqual({ llmWorkerId: 'w1', llmEffort: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('effort を変えると現在のワーカーを保ったまま PATCH される', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(
|
||||||
|
<ChatPane task={baseTask({ llmWorkerId: 'w1', llmEffort: null })} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||||
|
);
|
||||||
|
await openSelector(user);
|
||||||
|
const effortSelect = await screen.findByTestId('llm-select-effort');
|
||||||
|
await waitFor(() => expect(effortSelect).not.toBeDisabled());
|
||||||
|
await user.selectOptions(effortSelect, 'low');
|
||||||
|
|
||||||
|
await waitFor(() => expect(patchCalls.length).toBe(1));
|
||||||
|
expect(patchCalls[0].body).toEqual({ llmWorkerId: 'w1', llmEffort: 'low' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('自動に戻すと llmWorkerId/llmEffort が両方 null で PATCH される', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(
|
||||||
|
<ChatPane task={baseTask({ llmWorkerId: 'w1', llmEffort: 'high' })} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||||
|
);
|
||||||
|
await openSelector(user);
|
||||||
|
const workerSelect = await screen.findByTestId('llm-select-worker');
|
||||||
|
await waitFor(() => expect(screen.getByText('m1 @ w1')).toBeInTheDocument());
|
||||||
|
await user.selectOptions(workerSelect, '');
|
||||||
|
|
||||||
|
await waitFor(() => expect(patchCalls.length).toBe(1));
|
||||||
|
expect(patchCalls[0].body).toEqual({ llmWorkerId: null, llmEffort: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('実行中ジョブがあるときは、モデル切替ポップオーバーを開くと「次のジョブから有効」の注記を表示する', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(
|
||||||
|
<ChatPane
|
||||||
|
task={baseTask({ latestJob: { id: 'j1', status: 'running' } as LocalTask['latestJob'] })}
|
||||||
|
comments={[]}
|
||||||
|
onSubmit={vi.fn(async () => {})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 常時の横テキストは廃止。開く前は出ない。
|
||||||
|
expect(screen.queryByTestId('llm-applies-next-job')).not.toBeInTheDocument();
|
||||||
|
await openSelector(user);
|
||||||
|
expect(await screen.findByTestId('llm-applies-next-job')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('実行中でなければ、ポップオーバーを開いても注記は表示しない', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(
|
||||||
|
<ChatPane task={baseTask()} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||||
|
);
|
||||||
|
await openSelector(user);
|
||||||
|
await screen.findByTestId('llm-select-worker');
|
||||||
|
expect(screen.queryByTestId('llm-applies-next-job')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ピン留め先が workers 一覧の enabled に無い場合は停滞バッジを表示する', async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<ChatPane task={baseTask({ llmWorkerId: 'ghost-worker', llmEffort: null })} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||||
|
);
|
||||||
|
expect(await screen.findByTestId('llm-stalled-badge')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('停滞ワーカー + effort 設定済みでも effort select は無効化され、別の有効ワーカー選択と自動復帰は動く', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(
|
||||||
|
<ChatPane task={baseTask({ llmWorkerId: 'ghost-worker', llmEffort: 'high' })} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||||
|
);
|
||||||
|
await user.click(await screen.findByTestId('llm-select-trigger'));
|
||||||
|
|
||||||
|
// 停滞中は effort select を無効化(ゴースト id 由来の 400 を防ぐ)
|
||||||
|
const effortSelect = await screen.findByTestId('llm-select-effort');
|
||||||
|
expect(effortSelect).toBeDisabled();
|
||||||
|
|
||||||
|
// 別の有効なワーカーを選べば復帰できる → {newId, effort:null}
|
||||||
|
const workerSelect = screen.getByTestId('llm-select-worker');
|
||||||
|
await waitFor(() => expect(screen.getByText('m1 @ w1')).toBeInTheDocument());
|
||||||
|
await user.selectOptions(workerSelect, 'w1');
|
||||||
|
await waitFor(() => expect(patchCalls.length).toBe(1));
|
||||||
|
expect(patchCalls[0].body).toEqual({ llmWorkerId: 'w1', llmEffort: null });
|
||||||
|
|
||||||
|
// 自動に戻すボタンでも復帰できる → {null, null}
|
||||||
|
await user.click(screen.getByTestId('llm-select-clear-stalled'));
|
||||||
|
await waitFor(() => expect(patchCalls.length).toBe(2));
|
||||||
|
expect(patchCalls[1].body).toEqual({ llmWorkerId: null, llmEffort: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('停滞していないワーカーには停滞バッジを表示しない', async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<ChatPane task={baseTask({ llmWorkerId: 'w1', llmEffort: null })} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.getByTestId('llm-select-trigger')).toHaveTextContent('m1 @ w1'));
|
||||||
|
expect(screen.queryByTestId('llm-stalled-badge')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/api/llm/workers が非 OK(一時的な 500)を返しても停滞バッジは出さない(false-stalled 防止)', async () => {
|
||||||
|
// Given: fetchLlmWorkers が [] ではなく throw する(本 PR の修正)ので、
|
||||||
|
// react-query は error 状態になり isSuccess が false のまま。stalled 判定は
|
||||||
|
// isSuccess ゲート付きなので、健全なピン留めを誤って停滞扱いしない。
|
||||||
|
//
|
||||||
|
// 注意: トリガーのラベルはクエリ解決前から pinnedWorkerId をそのまま表示する
|
||||||
|
// (`pinnedWorker?.model ?? pinnedWorkerId`)ため、「トリガーに w1 が出る」を
|
||||||
|
// waitFor 条件にすると初回レンダーで即座に満たされてしまい、まだ設定中の
|
||||||
|
// クエリの結果を待たずにバッジ不在を早合点する(false green)。react-query の
|
||||||
|
// クエリ状態が pending を抜けるのを直接待ってから判定する。
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes('/api/llm/workers')) {
|
||||||
|
return new Response('server error', { status: 500 });
|
||||||
|
}
|
||||||
|
return new Response('{}', { status: 404 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { queryClient } = renderWithProviders(
|
||||||
|
<ChatPane task={baseTask({ llmWorkerId: 'w1', llmEffort: null })} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// クエリが pending を抜ける(このケースでは error に落ちる)まで待つ
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queryClient.getQueryState(['llm-workers'])?.status).not.toBe('pending');
|
||||||
|
});
|
||||||
|
expect(queryClient.getQueryState(['llm-workers'])?.status).toBe('error');
|
||||||
|
|
||||||
|
// トリガーは「w1」というピン留め id そのままを表示し(model 解決はできない)、
|
||||||
|
// かつ停滞バッジは表示されない。
|
||||||
|
expect(screen.getByTestId('llm-select-trigger')).toHaveTextContent('w1');
|
||||||
|
expect(screen.queryByTestId('llm-stalled-badge')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,21 +1,12 @@
|
|||||||
import { useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback, type CSSProperties } from 'react';
|
import { type CSSProperties } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LocalTask, LocalTaskComment } from '../../api';
|
import { LocalTask, LocalTaskComment } from '../../api';
|
||||||
import { ChatMessage } from './ChatMessage';
|
|
||||||
import { isThinkingComment, hasTrailingThinking } from './thinkingUtils';
|
|
||||||
import { ChatPetOverlay } from '../pets/ChatPetOverlay';
|
import { ChatPetOverlay } from '../pets/ChatPetOverlay';
|
||||||
import { ContextUsageGauge } from '../detail/ContextUsageGauge';
|
|
||||||
import { groupCommentsByMovement, MovementGroupExpanded } from './MovementGroup';
|
|
||||||
import { SubtaskInlineCard } from './SubtaskInlineCard';
|
|
||||||
import RotatingTips from './RotatingTips';
|
|
||||||
import { ToolRequestApproval } from './ToolRequestApproval';
|
|
||||||
import { PackageRequestApproval } from './PackageRequestApproval';
|
|
||||||
import { DelegateLiveConsole } from './DelegateLiveConsole';
|
|
||||||
import { useJobStream } from '../../hooks/useJobStream';
|
import { useJobStream } from '../../hooks/useJobStream';
|
||||||
import { useDraft } from '../../hooks/useDraft';
|
import { ChatComposer } from './ChatComposer';
|
||||||
import { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract';
|
import { ChatMessageFeed } from './ChatMessageFeed';
|
||||||
import { supportsFieldSizing, autosizeTextarea } from '../../lib/composerAutosize';
|
import { MovementMap } from './MovementMap';
|
||||||
import { toBase64 } from '../../lib/fileAttachments';
|
import { useMovementHistory } from '../../hooks/useTaskDetail';
|
||||||
|
|
||||||
interface ChatPaneProps {
|
interface ChatPaneProps {
|
||||||
task: LocalTask;
|
task: LocalTask;
|
||||||
@ -32,234 +23,12 @@ interface ChatPaneProps {
|
|||||||
export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activeDetailTab, onSelectDetailTab }: ChatPaneProps) {
|
export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activeDetailTab, onSelectDetailTab }: ChatPaneProps) {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const { t: dt } = useTranslation('detail');
|
const { t: dt } = useTranslation('detail');
|
||||||
// 下書き保存: ChatPane はチャット切替で remount されるため、初期値復元で足りる
|
|
||||||
const { draft: restoredDraft, saveDraft, clearDraft } = useDraft(`chat:${task.id}`);
|
|
||||||
const [draft, setDraft] = useState(restoredDraft ?? '');
|
|
||||||
const [attachments, setAttachments] = useState<Array<{ name: string; contentBase64: string }>>([]);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [cancelling, setCancelling] = useState(false);
|
|
||||||
const [sendError, setSendError] = useState<string | null>(null);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
// Firefox など field-sizing 未対応環境だけ JS で高さ追従(判定は初回のみ)。
|
|
||||||
const needsAutosizeFallback = useMemo(() => !supportsFieldSizing(), []);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!needsAutosizeFallback) return;
|
|
||||||
const el = composerRef.current;
|
|
||||||
if (el) autosizeTextarea(el, 192); // 192px ≈ 8行 (max-h-48 と一致させる)
|
|
||||||
}, [draft, needsAutosizeFallback]);
|
|
||||||
// Snapshot of comments.length at submit start. We hold "submitting" until
|
|
||||||
// (a) the new user comment is reflected in the list AND (b) the job is
|
|
||||||
// visibly busy (= picked up by a worker). Without this, the gap between the
|
|
||||||
// POST resolving and the worker dispatching the job lets the user fire
|
|
||||||
// multiple sends in a row.
|
|
||||||
const submitBaselineRef = useRef<number | null>(null);
|
|
||||||
const submitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
||||||
const [newMessageCount, setNewMessageCount] = useState(0);
|
|
||||||
const prevCommentCountRef = useRef(comments.length);
|
|
||||||
|
|
||||||
const checkIfAtBottom = useCallback(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return true;
|
|
||||||
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
|
||||||
if (scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
||||||
setIsAtBottom(true);
|
|
||||||
setNewMessageCount(0);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const handler = () => setIsAtBottom(checkIfAtBottom());
|
|
||||||
el.addEventListener('scroll', handler, { passive: true });
|
|
||||||
return () => el.removeEventListener('scroll', handler);
|
|
||||||
}, [checkIfAtBottom]);
|
|
||||||
|
|
||||||
// タスクを開いたとき(ChatPane はチャット切替で remount される)は、最新の
|
|
||||||
// メッセージが見える位置で開けるよう、初回描画前に最下部へジャンプする。
|
|
||||||
// useLayoutEffect にすることで一番上→最下部のちらつきを避ける。以降の追従は
|
|
||||||
// 上の comments.length 監視エフェクトが担う。
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const delta = comments.length - prevCommentCountRef.current;
|
|
||||||
prevCommentCountRef.current = comments.length;
|
|
||||||
if (delta <= 0) return;
|
|
||||||
if (isAtBottom) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setNewMessageCount(prev => prev + delta);
|
|
||||||
}
|
|
||||||
}, [comments.length, isAtBottom]);
|
|
||||||
|
|
||||||
const handleFiles = async (files: FileList | null) => {
|
|
||||||
if (!files || files.length === 0) return;
|
|
||||||
const converted = await Promise.all(
|
|
||||||
Array.from(files).map(async f => ({ name: f.name, contentBase64: await toBase64(f) }))
|
|
||||||
);
|
|
||||||
setAttachments(prev => [...prev, ...converted]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeAttachment = (name: string) => {
|
|
||||||
setAttachments(prev => prev.filter(a => a.name !== name));
|
|
||||||
};
|
|
||||||
|
|
||||||
const releaseSubmitting = () => {
|
|
||||||
if (submitTimeoutRef.current) {
|
|
||||||
clearTimeout(submitTimeoutRef.current);
|
|
||||||
submitTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
submitBaselineRef.current = null;
|
|
||||||
setSubmitting(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if ((!draft.trim() && attachments.length === 0) || submitting) return;
|
|
||||||
setSendError(null);
|
|
||||||
setSubmitting(true);
|
|
||||||
submitBaselineRef.current = comments.length;
|
|
||||||
try {
|
|
||||||
await onSubmit(draft, attachments.length > 0 ? attachments : undefined);
|
|
||||||
setDraft('');
|
|
||||||
setAttachments([]);
|
|
||||||
clearDraft();
|
|
||||||
// Hold the lock until the agent is visibly responding (see effect below).
|
|
||||||
// Safety net: if the worker never picks the job up (queue stuck, server
|
|
||||||
// crash, etc.), release the lock after 10s so the user isn't trapped.
|
|
||||||
if (submitTimeoutRef.current) clearTimeout(submitTimeoutRef.current);
|
|
||||||
submitTimeoutRef.current = setTimeout(releaseSubmitting, 10000);
|
|
||||||
} catch (e) {
|
|
||||||
setSendError(e instanceof Error && e.message ? e.message : t('pane.sendFailed'));
|
|
||||||
releaseSubmitting();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
|
||||||
const items = e.clipboardData?.items;
|
|
||||||
if (!items) return;
|
|
||||||
const files: File[] = [];
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
const item = items[i];
|
|
||||||
if (item.kind === 'file') {
|
|
||||||
const file = item.getAsFile();
|
|
||||||
if (file) files.push(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (files.length === 0) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const converted = await Promise.all(
|
|
||||||
files.map(async f => {
|
|
||||||
const name = f.name === 'image.png' ? `paste-${Date.now()}.png` : f.name;
|
|
||||||
return { name, contentBase64: await toBase64(f) };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
setAttachments(prev => [...prev, ...converted]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSubmit();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = async () => {
|
|
||||||
if (!onCancel || cancelling) return;
|
|
||||||
setCancelling(true);
|
|
||||||
try {
|
|
||||||
await onCancel();
|
|
||||||
} finally {
|
|
||||||
setCancelling(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const jobStatus = task.latestJob?.status;
|
const jobStatus = task.latestJob?.status;
|
||||||
const { promptProgress, streamingText, toolCallStream, connected, delegateStreams, llmState } = useJobStream(task.id, jobStatus);
|
|
||||||
|
|
||||||
// Most-recent content-field tool with decoded content to show live.
|
|
||||||
const liveToolContent = useMemo(() => {
|
|
||||||
const entries = Object.values(toolCallStream).filter(e => e.name in CONTENT_FIELD);
|
|
||||||
for (let k = entries.length - 1; k >= 0; k--) {
|
|
||||||
const text = extractStreamingField(entries[k].name, entries[k].rawArgs);
|
|
||||||
if (text) return { name: entries[k].name, text };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [toolCallStream]);
|
|
||||||
const liveToolRef = useRef<HTMLPreElement | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (liveToolRef.current) liveToolRef.current.scrollTop = liveToolRef.current.scrollHeight;
|
|
||||||
}, [liveToolContent?.text]);
|
|
||||||
const isBusy = jobStatus === 'running' || jobStatus === 'dispatching' || jobStatus === 'waiting_subtasks';
|
const isBusy = jobStatus === 'running' || jobStatus === 'dispatching' || jobStatus === 'waiting_subtasks';
|
||||||
const isWaitingSubtasks = jobStatus === 'waiting_subtasks';
|
const isWaitingSubtasks = jobStatus === 'waiting_subtasks';
|
||||||
const canInterject = jobStatus === 'running' || jobStatus === 'waiting_subtasks';
|
const jobStream = useJobStream(task.id, jobStatus);
|
||||||
const inputLocked = jobStatus === 'dispatching';
|
const movementHistory = useMovementHistory(task.id, isBusy);
|
||||||
// A job is already accepted but not yet running. The task is NOT idle: a new
|
const historyEvents = movementHistory.data ?? [];
|
||||||
// message must fold into this pending job (server reuses it, no duplicate),
|
|
||||||
// so the composer presents it as an addition rather than a fresh request.
|
|
||||||
const isPending = jobStatus === 'queued' || jobStatus === 'retry';
|
|
||||||
const hasActiveJob = isBusy || isPending;
|
|
||||||
|
|
||||||
// Tool-request mechanism: while the agent is paused waiting for the user to
|
|
||||||
// approve/deny a requested tool, lock the normal composer. Sending a regular
|
|
||||||
// message here would create a SECOND job (a waiting_human reply) that runs in
|
|
||||||
// parallel with the original once it's resumed by the approval → duplicate
|
|
||||||
// execution. Gate on the JOB's waitReason (available immediately from the
|
|
||||||
// task data) rather than the async tool-requests fetch, so there is no
|
|
||||||
// unlocked gap right after the task flips to waiting_human. A disabled
|
|
||||||
// textarea also blocks the Ctrl+Enter submit path.
|
|
||||||
const awaitingToolApproval =
|
|
||||||
jobStatus === 'waiting_human' && task.latestJob?.waitReason === 'tool_request';
|
|
||||||
const composerLocked = inputLocked || awaitingToolApproval;
|
|
||||||
|
|
||||||
// Release the submit lock once the agent is visibly responding: the new user
|
|
||||||
// comment is reflected in the list AND the job has been picked up by a worker
|
|
||||||
// (isBusy=true). This bridges the queued->dispatching gap where a stale
|
|
||||||
// re-enabled send button would otherwise allow a double submit.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!submitting) return;
|
|
||||||
const baseline = submitBaselineRef.current;
|
|
||||||
if (baseline === null) return;
|
|
||||||
if (comments.length > baseline && hasActiveJob) {
|
|
||||||
releaseSubmitting();
|
|
||||||
}
|
|
||||||
}, [submitting, comments.length, hasActiveJob]);
|
|
||||||
|
|
||||||
// Clear the safety-net timeout on unmount.
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (submitTimeoutRef.current) clearTimeout(submitTimeoutRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// During an active run, suppress the trailing thinking comment so the
|
|
||||||
// live SSE preview is the single source of truth for in-flight text.
|
|
||||||
// We keep the comment in history (MovementGroup will render it once the
|
|
||||||
// movement completes). On SSE disconnect we keep it visible as fallback.
|
|
||||||
const visibleComments = useMemo(() => {
|
|
||||||
if (!isBusy) return comments;
|
|
||||||
if (!connected) return comments; // SSE disconnect fallback
|
|
||||||
if (!hasTrailingThinking(comments)) return comments;
|
|
||||||
return comments.slice(0, -1);
|
|
||||||
}, [comments, isBusy, connected]);
|
|
||||||
|
|
||||||
const groupedItems = useMemo(() => groupCommentsByMovement(visibleComments), [visibleComments]);
|
|
||||||
const animatingIdx = isBusy && hasTrailingThinking(visibleComments) ? visibleComments.length - 1 : -1;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col h-full overflow-hidden">
|
<div className="relative flex flex-col h-full overflow-hidden">
|
||||||
@ -269,7 +38,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
|||||||
<ChatPetOverlay
|
<ChatPetOverlay
|
||||||
taskId={task.id}
|
taskId={task.id}
|
||||||
taskStatus={task.latestJob?.status ?? null}
|
taskStatus={task.latestJob?.status ?? null}
|
||||||
currentActivity={task.latestJob?.currentActivity ?? null}
|
lastToolEvent={jobStream.lastToolEvent}
|
||||||
workerId={task.latestJob?.workerId ?? null}
|
workerId={task.latestJob?.workerId ?? null}
|
||||||
lastBackendId={task.latestJob?.lastBackendId ?? null}
|
lastBackendId={task.latestJob?.lastBackendId ?? null}
|
||||||
/>
|
/>
|
||||||
@ -339,269 +108,11 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div className="flex-1 relative min-h-0 overflow-x-hidden">
|
<div className="relative flex min-h-0 flex-1">
|
||||||
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto overflow-x-hidden p-4">
|
<ChatMessageFeed task={task} comments={comments} jobStream={jobStream} historyEvents={historyEvents} />
|
||||||
<div className="max-w-3xl mx-auto min-w-0 flex flex-col gap-3">
|
<MovementMap task={task} comments={comments} historyEvents={historyEvents} />
|
||||||
{comments.length === 0 && (
|
|
||||||
<div className="text-center text-slate-400 text-[13px] py-8">
|
|
||||||
{t('pane.empty')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(() => {
|
|
||||||
let commentIdx = 0;
|
|
||||||
return groupedItems.map((item, gi) => {
|
|
||||||
if (item.type === 'movement') {
|
|
||||||
const startIdx = commentIdx;
|
|
||||||
commentIdx += item.inner.length;
|
|
||||||
return (
|
|
||||||
<MovementGroupExpanded
|
|
||||||
key={`mg-${gi}`}
|
|
||||||
item={item}
|
|
||||||
taskId={task.id}
|
|
||||||
isLast={gi === groupedItems.length - 1}
|
|
||||||
isRunning={isBusy}
|
|
||||||
animatingIdx={animatingIdx}
|
|
||||||
startIdx={startIdx}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const idx = commentIdx;
|
|
||||||
commentIdx++;
|
|
||||||
return (
|
|
||||||
<ChatMessage
|
|
||||||
key={item.comment.id}
|
|
||||||
comment={item.comment}
|
|
||||||
taskId={task.id}
|
|
||||||
isStaleThinking={isThinkingComment(item.comment) && idx !== animatingIdx}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{isWaitingSubtasks && task.subtasks && task.subtasks.length > 0 && (
|
|
||||||
<SubtaskInlineCard
|
|
||||||
subtasks={task.subtasks}
|
|
||||||
subtaskCount={task.subtaskCount ?? task.subtasks.length}
|
|
||||||
subtaskCompleted={task.subtaskCompleted ?? 0}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isBusy && !isWaitingSubtasks && (
|
|
||||||
<div className="flex justify-start">
|
|
||||||
{promptProgress ? (
|
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-surface border border-hairline rounded-md text-2xs text-slate-600 min-w-[180px]">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<span>{t('pane.processing')}</span>
|
|
||||||
<span className="font-mono tabular-nums">{promptProgress.percent}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-slate-200 rounded-full h-1">
|
|
||||||
<div
|
|
||||||
className="bg-emerald-500 h-1 rounded-full transition-all duration-300"
|
|
||||||
style={{ width: `${promptProgress.percent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : streamingText ? (
|
|
||||||
<div className="max-w-[80%] min-w-0 px-3 py-2 bg-canvas border border-hairline rounded-lg text-[13px] text-slate-800 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] opacity-70">
|
|
||||||
{streamingText}
|
|
||||||
<span className="inline-block w-0.5 h-3.5 bg-slate-400 animate-pulse ml-0.5 align-text-bottom" />
|
|
||||||
</div>
|
|
||||||
) : liveToolContent ? (
|
|
||||||
<div className="max-w-[80%] min-w-0 w-full px-3 py-2 bg-slate-50 border border-hairline rounded-lg">
|
|
||||||
<div className="text-2xs text-slate-500 mb-1 font-mono">{t('pane.generating', { name: liveToolContent.name })}</div>
|
|
||||||
<pre ref={liveToolRef} className="max-h-64 overflow-auto text-[12px] text-slate-800 whitespace-pre-wrap break-words [overflow-wrap:anywhere] m-0">
|
|
||||||
{liveToolContent.text}
|
|
||||||
<span className="inline-block w-0.5 h-3.5 bg-slate-400 animate-pulse ml-0.5 align-text-bottom" />
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="inline-flex items-center gap-2 px-2.5 py-1 bg-surface border border-hairline rounded-md text-2xs text-slate-600">
|
|
||||||
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
||||||
</svg>
|
|
||||||
{llmState?.phase === 'thinking' ? t('pane.llmThinking', { chars: (llmState.chars ?? 0).toLocaleString() })
|
|
||||||
: llmState?.phase === 'retrying' ? t('pane.llmRetrying', { attempt: llmState.attempt ?? 0, max: llmState.maxAttempts ?? 0, reason: llmState.reason ?? llmState.errorClass ?? '' })
|
|
||||||
: llmState?.phase === 'recovering' ? t('pane.llmRecovering', { stage: llmState.stage ?? '' })
|
|
||||||
: llmState?.phase === 'waiting' ? t('pane.llmWaiting')
|
|
||||||
: t('pane.agentResponding')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isBusy && Object.keys(delegateStreams).length > 0 && (
|
|
||||||
<div className="flex justify-start mt-1.5">
|
|
||||||
<DelegateLiveConsole streams={delegateStreams} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* General rotating tips to make the wait useful (running only). */}
|
|
||||||
{isBusy && <RotatingTips />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scroll-to-bottom button */}
|
|
||||||
{!isAtBottom && (
|
|
||||||
<button
|
|
||||||
onClick={scrollToBottom}
|
|
||||||
className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 px-3 py-1.5 bg-canvas border border-slate-200 rounded-full shadow-md text-xs text-slate-600 hover:bg-slate-50 transition-colors z-10"
|
|
||||||
>
|
|
||||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M4 6l4 4 4-4" />
|
|
||||||
</svg>
|
|
||||||
{newMessageCount > 0 ? (
|
|
||||||
<span className="text-blue-600 font-medium">{t('pane.newMessages', { count: newMessageCount })}</span>
|
|
||||||
) : (
|
|
||||||
<span>{t('pane.toLatest')}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Composer — カード+ツールバー型。上段 textarea(自動拡張)、下段に
|
|
||||||
添付・コンテキスト残量・送信系を集約。旧: 独立ゲージ行+2行 textarea で
|
|
||||||
約114px → 待機時約85px。 */}
|
|
||||||
<div className="flex-shrink-0 border-t border-hairline bg-canvas p-2.5" style={{ paddingBottom: 'calc(10px + env(safe-area-inset-bottom, 0px))' }}>
|
|
||||||
{hasActiveJob && (
|
|
||||||
<div className={`flex items-center gap-2 mb-2 px-2.5 py-1 rounded-md text-2xs ${
|
|
||||||
canInterject
|
|
||||||
? 'bg-amber-50 dark:bg-amber-500/15 border border-amber-100 dark:border-amber-500/30 text-amber-700 dark:text-amber-300'
|
|
||||||
: isPending
|
|
||||||
? 'bg-surface-2 border border-hairline text-slate-600 dark:text-slate-300'
|
|
||||||
: 'bg-blue-50 dark:bg-blue-500/15 border border-blue-100 dark:border-blue-500/30 text-blue-700 dark:text-blue-300'
|
|
||||||
}`}>
|
|
||||||
<svg className="w-3 h-3 animate-spin flex-shrink-0" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
||||||
</svg>
|
|
||||||
<span>{canInterject ? t('pane.interjectHint') : isPending ? t('pane.queuedHint') : t('pane.agentRunningWait')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ToolRequestApproval taskId={task.id} poll={jobStatus === 'waiting_human' || isBusy} />
|
|
||||||
<PackageRequestApproval taskId={task.id} poll={jobStatus === 'waiting_human' || isBusy} />
|
|
||||||
{sendError && !isBusy && (
|
|
||||||
<div className="flex items-center justify-between gap-2 mb-2 px-2.5 py-1 bg-red-50 dark:bg-red-500/15 border border-red-100 dark:border-red-500/30 rounded-md text-2xs text-red-700 dark:text-red-300">
|
|
||||||
<span className="truncate">⚠ {sendError}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleSubmit()}
|
|
||||||
disabled={submitting}
|
|
||||||
className="flex-shrink-0 px-2 h-6 bg-canvas border border-red-200 rounded text-[10px] font-medium text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-500/15 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t('pane.resend')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={`rounded-xl border transition-shadow ${composerLocked ? 'border-hairline bg-surface' : 'border-hairline bg-canvas focus-within:border-accent focus-within:ring-2 focus-within:ring-accent-ring'}`}>
|
|
||||||
<textarea
|
|
||||||
ref={composerRef}
|
|
||||||
value={draft}
|
|
||||||
onChange={e => { setDraft(e.target.value); saveDraft(e.target.value); }}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onPaste={e => void handlePaste(e)}
|
|
||||||
rows={1}
|
|
||||||
disabled={composerLocked}
|
|
||||||
placeholder={awaitingToolApproval ? t('toolRequest.composerLocked') : inputLocked ? t('pane.placeholder.dispatching') : canInterject ? t('pane.placeholder.interject') : isPending ? t('pane.placeholder.queued') : t('pane.placeholder.default')}
|
|
||||||
className="block w-full resize-none border-0 bg-transparent px-3 pt-2.5 pb-1 text-sm leading-6 text-slate-900 outline-none [field-sizing:content] min-h-6 max-h-48 overflow-y-auto disabled:text-slate-400 disabled:cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5 px-2 pb-2">
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={e => { void handleFiles(e.target.files); e.target.value = ''; }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={composerLocked || submitting}
|
|
||||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
|
|
||||||
title={t('pane.attachFile')}
|
|
||||||
aria-label={t('pane.attachFile')}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{attachments.length > 0 && (
|
|
||||||
<div className="flex max-h-12 min-w-0 flex-1 flex-wrap gap-1 overflow-y-auto">
|
|
||||||
{attachments.map(a => (
|
|
||||||
<span key={a.name} className="inline-flex items-center gap-1 rounded border border-hairline bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] text-slate-700">
|
|
||||||
{a.name}
|
|
||||||
<button onClick={() => removeAttachment(a.name)} className="ml-0.5 text-slate-400 hover:text-slate-700">×</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="ml-auto flex flex-shrink-0 items-center gap-2">
|
|
||||||
{/* コンテキスト残量: 入力しながら「あとどれくらい書けるか」を把握
|
|
||||||
できる位置に常時表示(issue #009 の趣旨を維持)。 */}
|
|
||||||
<ContextUsageGauge
|
|
||||||
inline
|
|
||||||
promptTokens={task.latestJob?.contextPromptTokens}
|
|
||||||
limitTokens={task.latestJob?.contextLimitTokens}
|
|
||||||
jobStatus={task.latestJob?.status}
|
|
||||||
/>
|
|
||||||
{isBusy && onCancel ? (
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
{canInterject && (
|
|
||||||
<button
|
|
||||||
disabled={submitting || (!draft.trim() && attachments.length === 0)}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-amber-100 text-amber-800 border border-amber-300 hover:bg-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30 dark:hover:bg-amber-500/25 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
||||||
<polyline points="15 10 20 15 15 20" />
|
|
||||||
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
|
|
||||||
</svg>
|
|
||||||
{t('pane.interject')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
disabled={cancelling}
|
|
||||||
onClick={() => void handleCancel()}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-canvas border border-red-200 text-red-700 hover:bg-red-50 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/15 disabled:opacity-50"
|
|
||||||
title={t('pane.stopAgent')}
|
|
||||||
>
|
|
||||||
<svg className="w-3 h-3" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<rect x="6" y="6" width="12" height="12" rx="2.5" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
{cancelling ? t('pane.stopping') : t('pane.stop')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : isPending ? (
|
|
||||||
<button
|
|
||||||
disabled={submitting || (!draft.trim() && attachments.length === 0)}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
title={t('pane.addToQueuedHint')}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-amber-100 text-amber-800 border border-amber-300 hover:bg-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30 dark:hover:bg-amber-500/25 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
||||||
<polyline points="15 10 20 15 15 20" />
|
|
||||||
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
|
|
||||||
</svg>
|
|
||||||
{t('pane.addToQueued')}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
disabled={submitting || composerLocked || (!draft.trim() && attachments.length === 0)}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 h-9 bg-accent text-accent-fg rounded-md text-xs font-semibold disabled:opacity-50 hover:bg-accent-deep flex-shrink-0 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
||||||
<path d="M22 2 11 13" />
|
|
||||||
<path d="M22 2 15 22 11 13 2 9 22 2Z" />
|
|
||||||
</svg>
|
|
||||||
{t('pane.send')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ChatComposer task={task} commentsLength={comments.length} onSubmit={onSubmit} onCancel={onCancel} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
215
ui/src/components/chat/ChatTimeline.tsx
Normal file
215
ui/src/components/chat/ChatTimeline.tsx
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { LocalTask, LocalTaskComment } from '../../api';
|
||||||
|
import { ChatMessage } from './ChatMessage';
|
||||||
|
import { isThinkingComment, hasTrailingThinking } from './thinkingUtils';
|
||||||
|
import { groupCommentsByMovement, MovementGroupExpanded } from './MovementGroup';
|
||||||
|
import { SubtaskInlineCard } from './SubtaskInlineCard';
|
||||||
|
import RotatingTips from './RotatingTips';
|
||||||
|
import { DelegateLiveConsole } from './DelegateLiveConsole';
|
||||||
|
import { useJobStream } from '../../hooks/useJobStream';
|
||||||
|
import { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract';
|
||||||
|
|
||||||
|
interface ChatTimelineProps {
|
||||||
|
task: LocalTask;
|
||||||
|
comments: LocalTaskComment[];
|
||||||
|
jobStatus: string | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatTimeline({ task, comments, jobStatus }: ChatTimelineProps) {
|
||||||
|
const { t } = useTranslation('chat');
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
|
const [newMessageCount, setNewMessageCount] = useState(0);
|
||||||
|
const prevCommentCountRef = useRef(comments.length);
|
||||||
|
|
||||||
|
const { promptProgress, streamingText, toolCallStream, connected, delegateStreams, llmState } = useJobStream(task.id, jobStatus);
|
||||||
|
const isBusy = jobStatus === 'running' || jobStatus === 'dispatching' || jobStatus === 'waiting_subtasks';
|
||||||
|
const isWaitingSubtasks = jobStatus === 'waiting_subtasks';
|
||||||
|
|
||||||
|
const checkIfAtBottom = useCallback(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return true;
|
||||||
|
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
setIsAtBottom(true);
|
||||||
|
setNewMessageCount(0);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const handler = () => setIsAtBottom(checkIfAtBottom());
|
||||||
|
el.addEventListener('scroll', handler, { passive: true });
|
||||||
|
return () => el.removeEventListener('scroll', handler);
|
||||||
|
}, [checkIfAtBottom]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const delta = comments.length - prevCommentCountRef.current;
|
||||||
|
prevCommentCountRef.current = comments.length;
|
||||||
|
if (delta <= 0) return;
|
||||||
|
if (isAtBottom) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setNewMessageCount(prev => prev + delta);
|
||||||
|
}
|
||||||
|
}, [comments.length, isAtBottom]);
|
||||||
|
|
||||||
|
const visibleComments = useMemo(() => {
|
||||||
|
if (!isBusy) return comments;
|
||||||
|
if (!connected) return comments;
|
||||||
|
if (!hasTrailingThinking(comments)) return comments;
|
||||||
|
return comments.slice(0, -1);
|
||||||
|
}, [comments, isBusy, connected]);
|
||||||
|
|
||||||
|
const groupedItems = useMemo(() => groupCommentsByMovement(visibleComments), [visibleComments]);
|
||||||
|
const animatingIdx = isBusy && hasTrailingThinking(visibleComments) ? visibleComments.length - 1 : -1;
|
||||||
|
|
||||||
|
const liveToolContent = useMemo(() => {
|
||||||
|
const entries = Object.values(toolCallStream).filter(e => e.name in CONTENT_FIELD);
|
||||||
|
for (let k = entries.length - 1; k >= 0; k--) {
|
||||||
|
const text = extractStreamingField(entries[k].name, entries[k].rawArgs);
|
||||||
|
if (text) return { name: entries[k].name, text };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [toolCallStream]);
|
||||||
|
const liveToolRef = useRef<HTMLPreElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (liveToolRef.current) liveToolRef.current.scrollTop = liveToolRef.current.scrollHeight;
|
||||||
|
}, [liveToolContent?.text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 relative min-h-0 overflow-x-hidden">
|
||||||
|
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto overflow-x-hidden p-4">
|
||||||
|
<div className="max-w-3xl mx-auto min-w-0 flex flex-col gap-3">
|
||||||
|
{comments.length === 0 && (
|
||||||
|
<div className="text-center text-slate-400 text-[13px] py-8">
|
||||||
|
{t('pane.empty')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
let commentIdx = 0;
|
||||||
|
return groupedItems.map((item, gi) => {
|
||||||
|
if (item.type === 'movement') {
|
||||||
|
const startIdx = commentIdx;
|
||||||
|
commentIdx += item.inner.length;
|
||||||
|
return (
|
||||||
|
<MovementGroupExpanded
|
||||||
|
key={`mg-${gi}`}
|
||||||
|
item={item}
|
||||||
|
taskId={task.id}
|
||||||
|
isLast={gi === groupedItems.length - 1}
|
||||||
|
isRunning={isBusy}
|
||||||
|
animatingIdx={animatingIdx}
|
||||||
|
startIdx={startIdx}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const idx = commentIdx;
|
||||||
|
commentIdx++;
|
||||||
|
return (
|
||||||
|
<ChatMessage
|
||||||
|
key={item.comment.id}
|
||||||
|
comment={item.comment}
|
||||||
|
taskId={task.id}
|
||||||
|
isStaleThinking={isThinkingComment(item.comment) && idx !== animatingIdx}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{isWaitingSubtasks && task.subtasks && task.subtasks.length > 0 && (
|
||||||
|
<SubtaskInlineCard
|
||||||
|
subtasks={task.subtasks}
|
||||||
|
subtaskCount={task.subtaskCount ?? task.subtasks.length}
|
||||||
|
subtaskCompleted={task.subtaskCompleted ?? 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isBusy && !isWaitingSubtasks && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
{promptProgress ? (
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-surface border border-hairline rounded-md text-2xs text-slate-600 min-w-[180px]">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span>{t('pane.processing')}</span>
|
||||||
|
<span className="font-mono tabular-nums">{promptProgress.percent}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-slate-200 rounded-full h-1">
|
||||||
|
<div className="bg-emerald-500 h-1 rounded-full transition-all duration-300" style={{ width: `${promptProgress.percent}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : streamingText ? (
|
||||||
|
<div className="max-w-[80%] min-w-0 px-3 py-2 bg-canvas border border-hairline rounded-lg text-[13px] text-slate-800 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] opacity-70">
|
||||||
|
{streamingText}
|
||||||
|
<span className="inline-block w-0.5 h-3.5 bg-slate-400 animate-pulse ml-0.5 align-text-bottom" />
|
||||||
|
</div>
|
||||||
|
) : liveToolContent ? (
|
||||||
|
<div className="max-w-[80%] min-w-0 w-full px-3 py-2 bg-slate-50 border border-hairline rounded-lg">
|
||||||
|
<div className="text-2xs text-slate-500 mb-1 font-mono">{t('pane.generating', { name: liveToolContent.name })}</div>
|
||||||
|
<pre ref={liveToolRef} className="max-h-64 overflow-auto text-[12px] text-slate-800 whitespace-pre-wrap break-words [overflow-wrap:anywhere] m-0">
|
||||||
|
{liveToolContent.text}
|
||||||
|
<span className="inline-block w-0.5 h-3.5 bg-slate-400 animate-pulse ml-0.5 align-text-bottom" />
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="inline-flex items-center gap-2 px-2.5 py-1 bg-surface border border-hairline rounded-md text-2xs text-slate-600">
|
||||||
|
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||||
|
</svg>
|
||||||
|
{llmState?.phase === 'thinking' ? t('pane.llmThinking', { chars: (llmState.chars ?? 0).toLocaleString() })
|
||||||
|
: llmState?.phase === 'retrying' ? t('pane.llmRetrying', { attempt: llmState.attempt ?? 0, max: llmState.maxAttempts ?? 0, reason: llmState.reason ?? llmState.errorClass ?? '' })
|
||||||
|
: llmState?.phase === 'recovering' ? t('pane.llmRecovering', { stage: llmState.stage ?? '' })
|
||||||
|
: llmState?.phase === 'waiting' ? t('pane.llmWaiting')
|
||||||
|
: t('pane.agentResponding')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isBusy && Object.keys(delegateStreams).length > 0 && (
|
||||||
|
<div className="flex justify-start mt-1.5">
|
||||||
|
<DelegateLiveConsole streams={delegateStreams} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isBusy && <RotatingTips />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isAtBottom && (
|
||||||
|
<button
|
||||||
|
onClick={scrollToBottom}
|
||||||
|
className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 px-3 py-1.5 bg-canvas border border-slate-200 rounded-full shadow-md text-xs text-slate-600 hover:bg-slate-50 transition-colors z-10"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M4 6l4 4 4-4" />
|
||||||
|
</svg>
|
||||||
|
{newMessageCount > 0 ? (
|
||||||
|
<span className="text-blue-600 font-medium">{t('pane.newMessages', { count: newMessageCount })}</span>
|
||||||
|
) : (
|
||||||
|
<span>{t('pane.toLatest')}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
272
ui/src/components/chat/LlmSelectionControl.tsx
Normal file
272
ui/src/components/chat/LlmSelectionControl.tsx
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* 会話コンポーザーの LLM 選択セレクター(Phase 1 Task 14)。
|
||||||
|
*
|
||||||
|
* タスクの現在の実効設定(sticky worker + reasoning effort)を表示し、
|
||||||
|
* クリックでポップオーバーを開いて変更できる。変更は即座に
|
||||||
|
* `PATCH /api/local/tasks/:id` で永続化され(sticky)、既に実行中の
|
||||||
|
* ジョブには影響しない — 次にディスパッチされるジョブから有効になる。
|
||||||
|
*
|
||||||
|
* 停滞バッジ: task.llmWorkerId が設定済みなのに、その id が
|
||||||
|
* `/api/llm/workers` の enabled ワーカー一覧に見つからない場合
|
||||||
|
* (無効化 or 設定から削除された)は、次のジョブが永久にそのワーカーの
|
||||||
|
* 空きを待ち続けてしまう。警告色のバッジで知らせ、ポップオーバーから
|
||||||
|
* 「自動」に戻せることを案内する。
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { LocalTask, fetchLlmWorkers, updateLocalTask } from '../../api';
|
||||||
|
import { computeModelSelectorPosition, type ModelSelectorPosition } from './modelSelectorPosition';
|
||||||
|
|
||||||
|
interface LlmSelectionControlProps {
|
||||||
|
task: LocalTask;
|
||||||
|
/** true when the task currently has a running/dispatching/waiting_subtasks job. */
|
||||||
|
busy?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LlmSelectionControl({ task, busy = false }: LlmSelectionControlProps) {
|
||||||
|
const { t } = useTranslation('chat');
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const focusFrameRef = useRef<number | null>(null);
|
||||||
|
const [panelPosition, setPanelPosition] = useState<ModelSelectorPosition | null>(null);
|
||||||
|
|
||||||
|
const workersQuery = useQuery({ queryKey: ['llm-workers'], queryFn: fetchLlmWorkers });
|
||||||
|
const workers = workersQuery.data ?? [];
|
||||||
|
const enabledWorkers = workers.filter(w => w.enabled !== false);
|
||||||
|
|
||||||
|
const mut = useMutation({
|
||||||
|
mutationFn: (sel: { llmWorkerId: string | null; llmEffort: string | null }) =>
|
||||||
|
updateLocalTask(task.id, sel),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['localTask', task.id] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['localTasks'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node;
|
||||||
|
if (!containerRef.current?.contains(target) && !panelRef.current?.contains(target)) setOpen(false);
|
||||||
|
};
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') { setOpen(false); triggerRef.current?.focus(); }
|
||||||
|
if (e.key === 'Tab' && panelRef.current) {
|
||||||
|
const focusable = Array.from(panelRef.current.querySelectorAll<HTMLElement>(
|
||||||
|
'button:not(:disabled), select:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
|
||||||
|
));
|
||||||
|
if (focusable.length === 0) return;
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(false);
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
const documentFocusable = Array.from(document.querySelectorAll<HTMLElement>(
|
||||||
|
'button:not(:disabled), select:not(:disabled), input:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
|
||||||
|
)).filter(element => !panelRef.current?.contains(element));
|
||||||
|
const triggerIndex = triggerRef.current ? documentFocusable.indexOf(triggerRef.current) : -1;
|
||||||
|
const next = triggerIndex >= 0 ? documentFocusable[triggerIndex + 1] : null;
|
||||||
|
setOpen(false);
|
||||||
|
next?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleMouseDown);
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleMouseDown);
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
if (focusFrameRef.current !== null) cancelAnimationFrame(focusFrameRef.current);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const updatePanelPosition = useCallback(() => {
|
||||||
|
const trigger = triggerRef.current;
|
||||||
|
const panel = panelRef.current;
|
||||||
|
if (!trigger || !panel) return;
|
||||||
|
const visualViewport = window.visualViewport;
|
||||||
|
setPanelPosition(computeModelSelectorPosition({
|
||||||
|
trigger: trigger.getBoundingClientRect(),
|
||||||
|
// Use the component's desired width instead of offsetWidth. During the
|
||||||
|
// first hidden Portal render the inline measured width is not available
|
||||||
|
// yet; feeding that zero back into state would permanently collapse it.
|
||||||
|
panelWidth: 260,
|
||||||
|
panelHeight: panel.scrollHeight,
|
||||||
|
viewportWidth: visualViewport?.width ?? window.innerWidth,
|
||||||
|
viewportHeight: visualViewport?.height ?? window.innerHeight,
|
||||||
|
viewportLeft: visualViewport?.offsetLeft ?? 0,
|
||||||
|
viewportTop: visualViewport?.offsetTop ?? 0,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setPanelPosition(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updatePanelPosition();
|
||||||
|
focusFrameRef.current = requestAnimationFrame(() => {
|
||||||
|
focusFrameRef.current = null;
|
||||||
|
panelRef.current?.querySelector<HTMLElement>('select:not(:disabled), button:not(:disabled)')?.focus();
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', updatePanelPosition);
|
||||||
|
window.addEventListener('scroll', updatePanelPosition, true);
|
||||||
|
window.visualViewport?.addEventListener('resize', updatePanelPosition);
|
||||||
|
window.visualViewport?.addEventListener('scroll', updatePanelPosition);
|
||||||
|
const resizeObserver = typeof ResizeObserver === 'undefined'
|
||||||
|
? null
|
||||||
|
: new ResizeObserver(updatePanelPosition);
|
||||||
|
if (panelRef.current) resizeObserver?.observe(panelRef.current);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', updatePanelPosition);
|
||||||
|
window.removeEventListener('scroll', updatePanelPosition, true);
|
||||||
|
window.visualViewport?.removeEventListener('resize', updatePanelPosition);
|
||||||
|
window.visualViewport?.removeEventListener('scroll', updatePanelPosition);
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
if (focusFrameRef.current !== null) cancelAnimationFrame(focusFrameRef.current);
|
||||||
|
};
|
||||||
|
}, [open, updatePanelPosition]);
|
||||||
|
|
||||||
|
const pinnedWorkerId = task.llmWorkerId ?? null;
|
||||||
|
const pinnedWorker = pinnedWorkerId ? workers.find(w => w.id === pinnedWorkerId) : undefined;
|
||||||
|
// ワーカー一覧の取得が終わっている(isSuccess)のに enabled 一覧に見当たらない
|
||||||
|
// = 無効化 or 設定から削除済み。ロード中は誤検知を避けるため判定しない。
|
||||||
|
const stalled = !!pinnedWorkerId && workersQuery.isSuccess && !enabledWorkers.some(w => w.id === pinnedWorkerId);
|
||||||
|
|
||||||
|
const triggerLabel = !pinnedWorkerId
|
||||||
|
? t('llmSelect.auto')
|
||||||
|
: `${pinnedWorker?.model ?? pinnedWorkerId} @ ${pinnedWorkerId}${task.llmEffort ? ` · ${task.llmEffort}` : ''}`;
|
||||||
|
|
||||||
|
const handleWorkerChange = (value: string) => {
|
||||||
|
// 切替時は必ず effort もリセット(新ワーカーが旧 effort に非対応だと API が 400 を返すため)。
|
||||||
|
mut.mutate({ llmWorkerId: value || null, llmEffort: null });
|
||||||
|
};
|
||||||
|
const handleEffortChange = (value: string) => {
|
||||||
|
mut.mutate({ llmWorkerId: pinnedWorkerId, llmEffort: value || null });
|
||||||
|
};
|
||||||
|
const clearToAuto = () => {
|
||||||
|
// 停滞解除: llmWorkerId/llmEffort を必ず両方 null で送る(片方だけ null だと
|
||||||
|
// 「effort はワーカー指定とセットのみ」400 になり得るため)。
|
||||||
|
mut.mutate({ llmWorkerId: null, llmEffort: null });
|
||||||
|
};
|
||||||
|
const initialPanelWidth = typeof window === 'undefined'
|
||||||
|
? 260
|
||||||
|
: Math.max(0, Math.min(260, (window.visualViewport?.width ?? window.innerWidth) - 24));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative flex flex-shrink-0 items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
data-testid="llm-select-trigger"
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={open ? 'llm-select-popover' : undefined}
|
||||||
|
title={t('llmSelect.title')}
|
||||||
|
aria-label={t('llmSelect.title')}
|
||||||
|
className={`inline-flex h-7 max-w-[220px] items-center gap-1 rounded-md border px-2 text-2xs font-medium transition-colors ${
|
||||||
|
stalled
|
||||||
|
? 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-300'
|
||||||
|
: open
|
||||||
|
? 'border-accent bg-accent-soft text-accent'
|
||||||
|
: 'border-hairline bg-canvas text-slate-600 hover:bg-surface-2 hover:text-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="h-3 w-3 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M12 2a4.5 4.5 0 0 0-4.5 4.5c0 1.6.8 2.98 2 3.82V13a2.5 2.5 0 0 0 5 0v-2.68a4.48 4.48 0 0 0 2-3.82A4.5 4.5 0 0 0 12 2Z" />
|
||||||
|
<path d="M9.5 17.5h5M10 21h4" />
|
||||||
|
</svg>
|
||||||
|
<span className="truncate">{triggerLabel}</span>
|
||||||
|
</button>
|
||||||
|
{stalled && (
|
||||||
|
<span
|
||||||
|
data-testid="llm-stalled-badge"
|
||||||
|
title={t('llmSelect.stalledWorker', { id: pinnedWorkerId })}
|
||||||
|
className="inline-flex items-center gap-1 rounded border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
{t('llmSelect.stalledWorker', { id: pinnedWorkerId })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{open && createPortal(
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
id="llm-select-popover"
|
||||||
|
data-testid="llm-select-popover"
|
||||||
|
data-placement={panelPosition?.placement ?? 'top'}
|
||||||
|
style={{
|
||||||
|
left: panelPosition?.left ?? 0,
|
||||||
|
top: panelPosition?.top ?? 0,
|
||||||
|
width: panelPosition?.width ?? initialPanelWidth,
|
||||||
|
maxHeight: panelPosition?.maxHeight,
|
||||||
|
visibility: panelPosition ? 'visible' : 'hidden',
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={t('llmSelect.title')}
|
||||||
|
className="fixed z-[80] flex min-w-0 flex-col gap-2 overflow-y-auto rounded-md border border-hairline bg-canvas p-2.5 shadow-xl"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-2xs text-slate-500">{t('llmSelect.workerLabel')}</label>
|
||||||
|
<select
|
||||||
|
data-testid="llm-select-worker"
|
||||||
|
value={pinnedWorkerId ?? ''}
|
||||||
|
onChange={e => handleWorkerChange(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-slate-200 px-2 py-1.5 text-xs outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="">{t('llmSelect.autoOption')}</option>
|
||||||
|
{enabledWorkers.map(w => (
|
||||||
|
<option key={w.id} value={w.id}>{`${w.model} @ ${w.id}`}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-2xs text-slate-500">{t('llmSelect.effortLabel')}</label>
|
||||||
|
<select
|
||||||
|
data-testid="llm-select-effort"
|
||||||
|
value={task.llmEffort ?? ''}
|
||||||
|
// 停滞(ゴースト id)の場合は effort をいじれないようにする。
|
||||||
|
// effort 変更は現在のワーカー id を保ったまま PATCH するため、
|
||||||
|
// 存在しないワーカー id で送ると server が 400 を返す。復旧は
|
||||||
|
// 「自動に戻す」または別の有効なワーカー選択のみに限定する。
|
||||||
|
disabled={!pinnedWorkerId || stalled}
|
||||||
|
onChange={e => handleEffortChange(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-slate-200 px-2 py-1.5 text-xs outline-none focus:border-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">{t('llmSelect.effortNone')}</option>
|
||||||
|
{(enabledWorkers.find(w => w.id === pinnedWorkerId)?.reasoningEfforts ?? []).map(effort => (
|
||||||
|
<option key={effort} value={effort}>{effort}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{stalled && (
|
||||||
|
<div className="flex items-start justify-between gap-2 rounded border border-amber-200 bg-amber-50 p-1.5 text-2xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-300">
|
||||||
|
<span>{t('llmSelect.stalledWorker', { id: pinnedWorkerId })}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="llm-select-clear-stalled"
|
||||||
|
onClick={clearToAuto}
|
||||||
|
className="flex-shrink-0 font-semibold underline hover:no-underline"
|
||||||
|
>
|
||||||
|
{t('llmSelect.autoOption')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{busy && (
|
||||||
|
<p data-testid="llm-applies-next-job" className="border-t border-hairline pt-1.5 text-[10px] text-slate-400">
|
||||||
|
{t('llmSelect.appliesNextJob')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,10 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LocalTaskComment } from '../../api';
|
import { LocalTaskComment, MovementHistoryEvent } from '../../api';
|
||||||
import { ChatMessage } from './ChatMessage';
|
import { ChatMessage } from './ChatMessage';
|
||||||
import { isThinkingComment } from './thinkingUtils';
|
import { isThinkingComment } from './thinkingUtils';
|
||||||
import { MarkdownText } from '../../lib/markdown-text';
|
import { MarkdownText } from '../../lib/markdown-text';
|
||||||
import { ToolCallsSection, parseToolCallComment, type ToolCallData } from './ToolCallsSection';
|
import { ToolCallsSection, parseToolCallComment, type ToolCallData } from './ToolCallsSection';
|
||||||
|
import { parseJobRetry } from './movementHistory';
|
||||||
|
|
||||||
interface ProgressData {
|
interface ProgressData {
|
||||||
movement: string;
|
movement: string;
|
||||||
@ -43,6 +44,7 @@ function getThinkingText(c: LocalTaskComment): string | null {
|
|||||||
|
|
||||||
export type ChatItem =
|
export type ChatItem =
|
||||||
| { type: 'comment'; comment: LocalTaskComment }
|
| { type: 'comment'; comment: LocalTaskComment }
|
||||||
|
| { type: 'diagnostic'; comment: LocalTaskComment }
|
||||||
| { type: 'movement'; movementName: string; summary: ProgressData; inner: LocalTaskComment[]; completionComment: LocalTaskComment };
|
| { type: 'movement'; movementName: string; summary: ProgressData; inner: LocalTaskComment[]; completionComment: LocalTaskComment };
|
||||||
|
|
||||||
export function groupCommentsByMovement(comments: LocalTaskComment[]): ChatItem[] {
|
export function groupCommentsByMovement(comments: LocalTaskComment[]): ChatItem[] {
|
||||||
@ -53,7 +55,13 @@ export function groupCommentsByMovement(comments: LocalTaskComment[]): ChatItem[
|
|||||||
c.kind === 'request' || c.kind === 'comment' || c.kind === 'interjection';
|
c.kind === 'request' || c.kind === 'comment' || c.kind === 'interjection';
|
||||||
|
|
||||||
for (const c of comments) {
|
for (const c of comments) {
|
||||||
if (isMovementCompleteComment(c)) {
|
if (c.kind === 'progress' && c.author === 'system' && parseJobRetry(c.body)) {
|
||||||
|
if (pendingInner.length > 0) {
|
||||||
|
for (const p of pendingInner) items.push({ type: 'comment', comment: p });
|
||||||
|
pendingInner = [];
|
||||||
|
}
|
||||||
|
items.push({ type: 'diagnostic', comment: c });
|
||||||
|
} else if (isMovementCompleteComment(c)) {
|
||||||
const summary = tryParseMovementComplete(c.body)!;
|
const summary = tryParseMovementComplete(c.body)!;
|
||||||
items.push({
|
items.push({
|
||||||
type: 'movement',
|
type: 'movement',
|
||||||
@ -114,16 +122,18 @@ interface MovementGroupExpandedProps {
|
|||||||
imageBaseUrl?: string;
|
imageBaseUrl?: string;
|
||||||
/** 共有ビューで添付チップ(input/ 配下・共有 API 非配信)を隠す。ChatMessage に委譲。 */
|
/** 共有ビューで添付チップ(input/ 配下・共有 API 非配信)を隠す。ChatMessage に委譲。 */
|
||||||
hideAttachments?: boolean;
|
hideAttachments?: boolean;
|
||||||
|
historyEvents?: MovementHistoryEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MovementGroupExpanded({ item, taskId, animatingIdx, startIdx, imageBaseUrl, hideAttachments }: MovementGroupExpandedProps) {
|
export function MovementGroupExpanded({ item, taskId, animatingIdx, startIdx, imageBaseUrl, hideAttachments, historyEvents = [] }: MovementGroupExpandedProps) {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const { movementName, summary, inner } = item;
|
const { movementName, summary, inner } = item;
|
||||||
const previewText = getPreviewText(item);
|
const previewText = getPreviewText(item);
|
||||||
|
const retryEvents = historyEvents;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div id={`movement-completion-${item.completionComment.id}`} tabIndex={-1} className="flex flex-col rounded outline-none transition-[background-color,box-shadow]">
|
||||||
{/* Header — always visible */}
|
{/* Header — always visible */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
@ -154,6 +164,17 @@ export function MovementGroupExpanded({ item, taskId, animatingIdx, startIdx, im
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{retryEvents.map(event => (
|
||||||
|
<div
|
||||||
|
key={event.eventId}
|
||||||
|
id={`trace-event-${event.eventId}`}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="ml-5 rounded border border-amber-200/70 bg-amber-50/70 px-2 py-1 text-[11px] text-amber-800 outline-none dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
{t('movementMap.llmRetry', { attempt: event.payload.attempt ?? '?', max: event.payload.maxAttempts ?? '?' })}
|
||||||
|
{event.payload.errorClass ? ` · ${event.payload.errorClass}` : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Expanded: render inner comments in chronological order. Consecutive
|
{/* Expanded: render inner comments in chronological order. Consecutive
|
||||||
tool_call comments are merged into one ToolCallsSection so the
|
tool_call comments are merged into one ToolCallsSection so the
|
||||||
|
|||||||
151
ui/src/components/chat/MovementMap.test.tsx
Normal file
151
ui/src/components/chat/MovementMap.test.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import '../../test/dom-setup';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import type { LocalTask, MovementHistoryEvent } from '../../api';
|
||||||
|
import { MovementMap, summarizeMovementInstruction } from './MovementMap';
|
||||||
|
|
||||||
|
const longInstruction = `Build the approved change with the smallest possible UI. ${'Keep the original behavior and verify accessibility. '.repeat(6)}`;
|
||||||
|
const activePetMock = vi.hoisted(() => ({ data: null as any }));
|
||||||
|
const pieceMock = vi.hoisted(() => ({ movements: [
|
||||||
|
{ name: 'investigate', instruction: 'Inspect the current behavior.' },
|
||||||
|
{ name: 'implement', instruction: '' },
|
||||||
|
] as Array<{ name: string; instruction: string }> }));
|
||||||
|
pieceMock.movements[1]!.instruction = longInstruction;
|
||||||
|
|
||||||
|
vi.mock('../../hooks/usePieces', () => ({
|
||||||
|
usePiece: () => ({ data: { piece: { movements: pieceMock.movements } } }),
|
||||||
|
}));
|
||||||
|
vi.mock('../../hooks/useActivePet', () => ({ useActivePet: () => ({ data: activePetMock.data }) }));
|
||||||
|
|
||||||
|
function task(status: string, currentMovement: string): LocalTask {
|
||||||
|
return {
|
||||||
|
id: 8,
|
||||||
|
title: 'UI work',
|
||||||
|
body: 'Improve UI',
|
||||||
|
pieceName: 'build',
|
||||||
|
profile: 'auto',
|
||||||
|
outputFormat: 'markdown',
|
||||||
|
askPolicy: 'low',
|
||||||
|
priority: 'medium',
|
||||||
|
state: status,
|
||||||
|
workspacePath: null,
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
latestJob: { id: 'j8', status, currentMovement },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MovementMap', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
activePetMock.data = null;
|
||||||
|
pieceMock.movements = [
|
||||||
|
{ name: 'investigate', instruction: 'Inspect the current behavior.' },
|
||||||
|
{ name: 'implement', instruction: longInstruction },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
it('renders as a compact overlay rail without showing the instruction on focus', () => {
|
||||||
|
render(<MovementMap task={task('running', 'implement')} comments={[]} />);
|
||||||
|
const rail = screen.getByTestId('movement-rail');
|
||||||
|
expect(rail).toHaveClass('absolute', 'right-1.5');
|
||||||
|
const trigger = screen.getByRole('button', { name: /implement/ });
|
||||||
|
fireEvent.focus(trigger);
|
||||||
|
expect(screen.queryByText(longInstruction)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('implement')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a short purpose, manages focus, and expands the full instruction explicitly', async () => {
|
||||||
|
render(<MovementMap task={task('running', 'implement')} comments={[]} />);
|
||||||
|
const trigger = screen.getByRole('button', { name: /implement/ });
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
const details = screen.getByRole('region', { name: 'implement' });
|
||||||
|
const closeButton = screen.getByRole('button', { name: /movementMap\.closeInstruction|Close movement|詳細を閉じる/ });
|
||||||
|
await waitFor(() => expect(closeButton).toHaveFocus());
|
||||||
|
expect(details).toHaveTextContent('Build the approved change with the smallest possible UI.');
|
||||||
|
expect(details).not.toHaveTextContent(longInstruction);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /movementMap\.showFullInstruction|Show full instruction|全文を見る/ }));
|
||||||
|
expect(details.querySelector('p')?.textContent).toBe(longInstruction.trim());
|
||||||
|
fireEvent.keyDown(window, { key: 'Escape' });
|
||||||
|
expect(screen.queryByRole('region', { name: 'implement' })).not.toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(trigger).toHaveFocus());
|
||||||
|
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
const reopenedClose = await screen.findByRole('button', { name: /movementMap\.closeInstruction|Close movement|詳細を閉じる/ });
|
||||||
|
fireEvent.click(reopenedClose);
|
||||||
|
await waitFor(() => expect(trigger).toHaveFocus());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the instruction for a completed history point and offers navigation', () => {
|
||||||
|
const historyEvents: MovementHistoryEvent[] = [{
|
||||||
|
eventId: 'start-1', ts: '2026-07-12T00:00:00.000Z', seq: 1, line: 1, runId: 'run-1',
|
||||||
|
kind: 'movement_start', movement: 'investigate', iteration: 0, payload: {},
|
||||||
|
}, {
|
||||||
|
eventId: 'complete-1', ts: '2026-07-12T00:00:01.000Z', seq: 2, line: 2, runId: 'run-1',
|
||||||
|
kind: 'movement_complete', movement: 'investigate', iteration: 0, payload: {},
|
||||||
|
}];
|
||||||
|
const comments = [{
|
||||||
|
id: 41, taskId: 8, author: 'agent', kind: 'progress' as const,
|
||||||
|
body: JSON.stringify({ movement: 'investigate', durationMs: 1000, tools: {} }),
|
||||||
|
createdAt: '2026-07-12T00:00:01.000Z', injectedAt: null,
|
||||||
|
}];
|
||||||
|
render(<MovementMap task={task('succeeded', 'investigate')} comments={comments} historyEvents={historyEvents} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'investigate' }));
|
||||||
|
expect(screen.getByRole('region', { name: 'investigate' })).toHaveTextContent('Inspect the current behavior.');
|
||||||
|
expect(screen.getByRole('button', { name: /movementMap\.goToMovement|Go to this movement|このMovementへ移動/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks expanded state independently for repeated history movements', () => {
|
||||||
|
const historyEvents: MovementHistoryEvent[] = [
|
||||||
|
{ eventId: 's1', ts: '2026-07-12T00:00:00.000Z', seq: 1, line: 1, runId: 'r1', kind: 'movement_start', movement: 'investigate', iteration: 0, payload: {} },
|
||||||
|
{ eventId: 'c1', ts: '2026-07-12T00:00:01.000Z', seq: 2, line: 2, runId: 'r1', kind: 'movement_complete', movement: 'investigate', iteration: 0, payload: {} },
|
||||||
|
{ eventId: 's2', ts: '2026-07-12T00:00:02.000Z', seq: 3, line: 3, runId: 'r1', kind: 'movement_start', movement: 'investigate', iteration: 1, payload: {} },
|
||||||
|
{ eventId: 'c2', ts: '2026-07-12T00:00:03.000Z', seq: 4, line: 4, runId: 'r1', kind: 'movement_complete', movement: 'investigate', iteration: 1, payload: {} },
|
||||||
|
];
|
||||||
|
const comments = [41, 42].map((id, index) => ({
|
||||||
|
id, taskId: 8, author: 'agent', kind: 'progress' as const,
|
||||||
|
body: JSON.stringify({ movement: 'investigate', durationMs: 1000, tools: {} }),
|
||||||
|
createdAt: `2026-07-12T00:00:0${index * 2 + 1}.000Z`, injectedAt: null,
|
||||||
|
}));
|
||||||
|
render(<MovementMap task={task('succeeded', 'investigate')} comments={comments} historyEvents={historyEvents} />);
|
||||||
|
const triggers = screen.getAllByRole('button', { name: 'investigate' });
|
||||||
|
fireEvent.click(triggers[0]!);
|
||||||
|
expect(triggers[0]).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
expect(triggers[1]).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('distinguishes a failed movement from pending movements', () => {
|
||||||
|
render(<MovementMap task={task('failed', 'implement')} comments={[]} />);
|
||||||
|
expect(screen.getByLabelText(/Failed|失敗/)).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByLabelText(/Pending|待機中/).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summarizes deterministically without inventing text', () => {
|
||||||
|
expect(summarizeMovementInstruction(' First sentence.\nSecond sentence. ')).toBe('First sentence.');
|
||||||
|
expect(summarizeMovementInstruction('目的を確認する。次に実装する。')).toBe('目的を確認する。');
|
||||||
|
expect(summarizeMovementInstruction('abcdef', 4)).toBe('abc…');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes details when the task changes', () => {
|
||||||
|
const rendered = render(<MovementMap task={task('running', 'implement')} comments={[]} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /implement/ }));
|
||||||
|
expect(screen.getByRole('region', { name: 'implement' })).toBeInTheDocument();
|
||||||
|
rendered.rerender(<MovementMap task={{ ...task('running', 'implement'), id: 9 }} comments={[]} />);
|
||||||
|
expect(screen.queryByRole('region', { name: 'implement' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places the active Pet in a separate lane beside the dot', () => {
|
||||||
|
activePetMock.data = {
|
||||||
|
pet: { name: 'Test pet' }, imageUrl: null, frameWidth: null, frameHeight: null,
|
||||||
|
gridCols: null, gridRows: null, settings: { enabled: true, reducedMotion: true },
|
||||||
|
};
|
||||||
|
render(<MovementMap task={task('running', 'implement')} comments={[]} />);
|
||||||
|
expect(screen.getByTestId('movement-active-pet')).toHaveClass('right-full');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a long custom Piece reachable with a scrollable rail', () => {
|
||||||
|
pieceMock.movements = Array.from({ length: 20 }, (_, index) => ({ name: `step-${index + 1}`, instruction: `Do step ${index + 1}.` }));
|
||||||
|
render(<MovementMap task={task('running', 'step-10')} comments={[]} />);
|
||||||
|
expect(screen.getByTestId('movement-rail')).toHaveClass('max-h-[calc(100%-1rem)]', 'overflow-y-auto');
|
||||||
|
expect(screen.getByRole('button', { name: /step-20/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
304
ui/src/components/chat/MovementMap.tsx
Normal file
304
ui/src/components/chat/MovementMap.tsx
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { LocalTask, LocalTaskComment, MovementHistoryEvent } from '../../api';
|
||||||
|
import { usePiece } from '../../hooks/usePieces';
|
||||||
|
import { useActivePet } from '../../hooks/useActivePet';
|
||||||
|
import { PetSprite } from '../pets/PetSprite';
|
||||||
|
import { groupCommentsByMovement } from './MovementGroup';
|
||||||
|
import { buildMovementRailItems, type MovementRailItem } from './movementHistory';
|
||||||
|
|
||||||
|
interface MovementDefinition {
|
||||||
|
name?: string;
|
||||||
|
instruction?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MovementStatus = 'completed' | 'active' | 'pending' | 'failed' | 'cancelled';
|
||||||
|
|
||||||
|
const formatDuration = (ms: number): string => {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
const seconds = Math.round(ms / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const summarizeMovementInstruction = (instruction: string, maxLength = 160): string => {
|
||||||
|
const normalized = instruction.replace(/\s+/g, ' ').trim();
|
||||||
|
const firstSentence = normalized.match(/^.*?(?:[。!?]|[.!?](?=\s|$)|$)/)?.[0] ?? normalized;
|
||||||
|
if (firstSentence.length <= maxLength) return firstSentence;
|
||||||
|
return `${firstSentence.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dotTone: Record<MovementStatus, string> = {
|
||||||
|
completed: 'border-emerald-500/70 bg-emerald-500/70',
|
||||||
|
active: 'border-blue-500 bg-blue-500 ring-2 ring-blue-300/50',
|
||||||
|
pending: 'border-slate-400/60 bg-canvas/80',
|
||||||
|
failed: 'border-red-500 bg-red-500',
|
||||||
|
cancelled: 'border-slate-500 bg-slate-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MovementMap({ task, comments, historyEvents = [] }: { task: LocalTask; comments: LocalTaskComment[]; historyEvents?: MovementHistoryEvent[] }) {
|
||||||
|
const { t } = useTranslation('chat');
|
||||||
|
const pieceQuery = usePiece(task.pieceName, undefined, task.spaceId ?? undefined);
|
||||||
|
const { data: activePet } = useActivePet(task.latestJob?.workerId, task.latestJob?.lastBackendId);
|
||||||
|
const [hoveredName, setHoveredName] = useState<string | null>(null);
|
||||||
|
const [selected, setSelected] = useState<{ key: string; name: string; body: string; trigger: HTMLButtonElement; targetId?: string } | null>(null);
|
||||||
|
const [showFull, setShowFull] = useState(false);
|
||||||
|
const detailCloseRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const closeDetails = (restoreFocus: boolean) => {
|
||||||
|
const trigger = selected?.trigger;
|
||||||
|
setSelected(null);
|
||||||
|
setShowFull(false);
|
||||||
|
if (restoreFocus) window.requestAnimationFrame(() => trigger?.focus());
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(null);
|
||||||
|
setShowFull(false);
|
||||||
|
}, [task.id, task.pieceName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected) window.requestAnimationFrame(() => detailCloseRef.current?.focus());
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected) return;
|
||||||
|
const close = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeDetails(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const dismiss = (event: PointerEvent) => {
|
||||||
|
const target = event.target as Element | null;
|
||||||
|
if (target?.closest('[data-movement-trigger], [data-movement-detail]')) return;
|
||||||
|
closeDetails(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', close);
|
||||||
|
window.addEventListener('pointerdown', dismiss);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', close);
|
||||||
|
window.removeEventListener('pointerdown', dismiss);
|
||||||
|
};
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
const completed = useMemo(() => {
|
||||||
|
const result = new Map<string, number>();
|
||||||
|
for (const item of groupCommentsByMovement(comments)) {
|
||||||
|
if (item.type === 'movement') result.set(item.movementName, item.summary.durationMs);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [comments]);
|
||||||
|
|
||||||
|
const definitions = (pieceQuery.data?.piece.movements ?? []) as MovementDefinition[];
|
||||||
|
const definitionsByName = useMemo(
|
||||||
|
() => new Map(definitions.filter(item => item.name).map(item => [item.name!, item])),
|
||||||
|
[definitions],
|
||||||
|
);
|
||||||
|
const movements = definitions.length > 0 ? [...definitions, { name: 'COMPLETE' }] : [];
|
||||||
|
const historyItems = useMemo(
|
||||||
|
() => buildMovementRailItems(historyEvents, comments, movements.map(item => item.name ?? '')),
|
||||||
|
[historyEvents, comments, movements.map(item => item.name).join('\u0000')],
|
||||||
|
);
|
||||||
|
const executedNames = useMemo(
|
||||||
|
() => new Set(historyItems.filter(item => item.type === 'movement').map(item => item.name)),
|
||||||
|
[historyItems],
|
||||||
|
);
|
||||||
|
const plannedMovements = historyEvents.length > 0
|
||||||
|
? movements.filter(item => !executedNames.has(item.name ?? ''))
|
||||||
|
: movements;
|
||||||
|
if (movements.length === 0) return null;
|
||||||
|
|
||||||
|
const current = task.latestJob?.currentMovement;
|
||||||
|
const jobStatus = task.latestJob?.status;
|
||||||
|
const terminal = ['succeeded', 'failed', 'cancelled'].includes(jobStatus ?? '');
|
||||||
|
|
||||||
|
const navigateTo = (targetId: string) => {
|
||||||
|
const target = document.getElementById(targetId);
|
||||||
|
if (!target) return;
|
||||||
|
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
target.scrollIntoView({ behavior: reducedMotion ? 'auto' : 'smooth', block: 'center' });
|
||||||
|
target.focus({ preventScroll: true });
|
||||||
|
target.classList.add('ring-2', 'ring-accent-ring', 'bg-accent/10');
|
||||||
|
window.setTimeout(() => target.classList.remove('ring-2', 'ring-accent-ring', 'bg-accent/10'), 2200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconFor = (item: MovementRailItem): string => {
|
||||||
|
if (item.type === 'return') return '↩';
|
||||||
|
if (item.type === 'llm_retry') return '↻';
|
||||||
|
if (item.type === 'job_retry') return '⇄';
|
||||||
|
if (item.type === 'user_input') return '●';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const toneFor = (item: MovementRailItem): string => {
|
||||||
|
if (item.type === 'return') return 'border-orange-500 bg-orange-100 text-orange-700';
|
||||||
|
if (item.type === 'llm_retry') return 'border-amber-500 bg-amber-100 text-amber-700';
|
||||||
|
if (item.type === 'job_retry') return 'border-orange-600 bg-orange-100 text-orange-800';
|
||||||
|
if (item.type === 'user_input') return 'border-blue-500 bg-blue-100 text-blue-700';
|
||||||
|
return item.status === 'completed' ? dotTone.completed : dotTone.active;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusFor = (name: string, duration?: number): MovementStatus => {
|
||||||
|
if (jobStatus === 'succeeded' && name === 'COMPLETE') return 'completed';
|
||||||
|
if ((jobStatus === 'failed' || jobStatus === 'cancelled') && name === current) return jobStatus;
|
||||||
|
if (!terminal && name === current) return 'active';
|
||||||
|
if (duration != null) return 'completed';
|
||||||
|
return 'pending';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<aside
|
||||||
|
className="pointer-events-none absolute right-1.5 top-1/2 z-20 max-h-[calc(100%-1rem)] -translate-y-1/2 overflow-y-auto [scrollbar-width:thin]"
|
||||||
|
aria-label={t('movementMap.label')}
|
||||||
|
data-testid="movement-rail"
|
||||||
|
>
|
||||||
|
<ol className="pointer-events-auto flex flex-col items-center py-1">
|
||||||
|
{historyItems.map((item) => {
|
||||||
|
const hovered = hoveredName === item.id;
|
||||||
|
const label = item.type === 'movement' ? item.name : `${item.name}: ${item.type.replace('_', ' ')}`;
|
||||||
|
const definition = item.type === 'movement' ? definitionsByName.get(item.name) : undefined;
|
||||||
|
const instruction = definition?.instruction?.trim();
|
||||||
|
return (
|
||||||
|
<li key={item.id} className="relative flex flex-col items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-movement-trigger
|
||||||
|
aria-label={label}
|
||||||
|
className="group relative grid h-5 w-5 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
|
||||||
|
onMouseEnter={() => setHoveredName(item.id)}
|
||||||
|
onMouseLeave={() => setHoveredName(null)}
|
||||||
|
onFocus={() => setHoveredName(item.id)}
|
||||||
|
onBlur={() => setHoveredName(null)}
|
||||||
|
aria-expanded={instruction ? selected?.key === item.id : undefined}
|
||||||
|
aria-controls={instruction ? 'movement-detail-popover' : undefined}
|
||||||
|
onClick={event => {
|
||||||
|
if (!instruction) {
|
||||||
|
if (item.targetId) navigateTo(item.targetId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selected?.key === item.id) {
|
||||||
|
closeDetails(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelected({ key: item.id, name: item.name, body: instruction, trigger: event.currentTarget, targetId: item.targetId });
|
||||||
|
setShowFull(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={`grid h-3 w-3 place-items-center rounded-full border text-[8px] font-bold leading-none ${toneFor(item)}`} aria-hidden="true">
|
||||||
|
{iconFor(item)}
|
||||||
|
</span>
|
||||||
|
{hovered && (
|
||||||
|
<span className="absolute right-full mr-1.5 whitespace-nowrap rounded-md bg-slate-900/80 px-1.5 py-0.5 text-[10px] font-medium text-white shadow-sm backdrop-blur-sm">
|
||||||
|
{label}{'detail' in item && item.detail ? <span className="ml-1 text-white/65">{item.detail}</span> : null}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<span className="h-1.5 w-px bg-slate-400/25" aria-hidden="true" />
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{plannedMovements.map((movement, index) => {
|
||||||
|
const name = movement.name || t('movementMap.unnamed');
|
||||||
|
const duration = completed.get(name);
|
||||||
|
const status = statusFor(name, duration);
|
||||||
|
const active = status === 'active';
|
||||||
|
const hovered = hoveredName === name;
|
||||||
|
const hasInstruction = !!movement.instruction?.trim();
|
||||||
|
const selectionKey = `planned-${name}-${index}`;
|
||||||
|
return (
|
||||||
|
<li key={`${name}-${index}`} className="relative flex flex-col items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-movement-trigger
|
||||||
|
aria-label={`${name}: ${t(`movementMap.${status}`)}`}
|
||||||
|
aria-expanded={hasInstruction ? selected?.key === selectionKey : undefined}
|
||||||
|
aria-controls={hasInstruction ? 'movement-detail-popover' : undefined}
|
||||||
|
className="group relative grid h-6 w-6 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
|
||||||
|
onMouseEnter={() => setHoveredName(name)}
|
||||||
|
onMouseLeave={() => setHoveredName(null)}
|
||||||
|
onFocus={() => setHoveredName(name)}
|
||||||
|
onBlur={() => setHoveredName(null)}
|
||||||
|
onClick={event => {
|
||||||
|
if (!hasInstruction) return;
|
||||||
|
if (selected?.key === selectionKey) {
|
||||||
|
closeDetails(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelected({ key: selectionKey, name, body: movement.instruction!.trim(), trigger: event.currentTarget });
|
||||||
|
setShowFull(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={`h-2 w-2 rounded-full border ${dotTone[status]}`} aria-hidden="true" />
|
||||||
|
{active && activePet?.settings.enabled && (
|
||||||
|
<span data-testid="movement-active-pet" className="absolute right-full top-1/2 mr-0.5 -translate-y-1/2">
|
||||||
|
<PetSprite
|
||||||
|
name={activePet.pet?.name ?? 'Pet'}
|
||||||
|
imageUrl={activePet.imageUrl}
|
||||||
|
frameWidth={activePet.frameWidth}
|
||||||
|
frameHeight={activePet.frameHeight}
|
||||||
|
gridCols={activePet.gridCols}
|
||||||
|
gridRows={activePet.gridRows}
|
||||||
|
framesPerRow={null}
|
||||||
|
state="running"
|
||||||
|
size={18}
|
||||||
|
reducedMotion={activePet.settings.reducedMotion}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hovered && (
|
||||||
|
<span className={`absolute right-full whitespace-nowrap rounded-md bg-slate-900/75 px-1.5 py-0.5 text-[10px] font-medium text-white shadow-sm backdrop-blur-sm ${activePet?.settings.enabled && active ? 'mr-6' : 'mr-1.5'}`}>
|
||||||
|
<span className="inline-block max-w-28 truncate align-bottom">{name}</span>
|
||||||
|
{hovered && <span className="ml-1 text-white/65">{duration != null ? formatDuration(duration) : t(`movementMap.${status}`)}</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{index < plannedMovements.length - 1 && (
|
||||||
|
<span className={`h-2.5 w-px ${status === 'completed' ? 'bg-emerald-400/45' : 'bg-slate-400/25'}`} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{selected && createPortal(
|
||||||
|
<div
|
||||||
|
data-movement-detail
|
||||||
|
id="movement-detail-popover"
|
||||||
|
className="fixed right-10 top-1/2 z-[70] max-h-[calc(100dvh-2rem)] w-[min(18rem,calc(100vw-4rem))] -translate-y-1/2 overflow-y-auto rounded-lg border border-white/40 bg-canvas/90 p-3 text-xs text-slate-700 shadow-xl backdrop-blur-md dark:border-slate-700/60 dark:text-slate-200"
|
||||||
|
role="region"
|
||||||
|
aria-label={selected.name}
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-start justify-between gap-2">
|
||||||
|
<strong className="truncate">{selected.name}</strong>
|
||||||
|
<button ref={detailCloseRef} type="button" onClick={() => closeDetails(true)} className="grid h-6 w-6 place-items-center rounded text-slate-400 hover:bg-surface hover:text-slate-700" aria-label={t('movementMap.closeInstruction')}>×</button>
|
||||||
|
</div>
|
||||||
|
<p className={showFull ? 'max-h-60 overflow-y-auto whitespace-pre-wrap leading-relaxed' : 'line-clamp-2 leading-relaxed'}>
|
||||||
|
{showFull ? selected.body : summarizeMovementInstruction(selected.body)}
|
||||||
|
</p>
|
||||||
|
{selected.body !== summarizeMovementInstruction(selected.body) && (
|
||||||
|
<button type="button" onClick={() => setShowFull(value => !value)} className="mt-2 text-2xs font-semibold text-accent hover:underline">
|
||||||
|
{showFull ? t('movementMap.collapseInstruction') : t('movementMap.showFullInstruction')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{selected.targetId && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const targetId = selected.targetId;
|
||||||
|
closeDetails(false);
|
||||||
|
if (targetId) window.requestAnimationFrame(() => navigateTo(targetId));
|
||||||
|
}}
|
||||||
|
className="mt-2 ml-3 text-2xs font-semibold text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{t('movementMap.goToMovement')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
ui/src/components/chat/ScrollToLatestButton.test.tsx
Normal file
40
ui/src/components/chat/ScrollToLatestButton.test.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import '../../test/dom-setup';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
import i18n from '../../i18n';
|
||||||
|
import { ScrollToLatestButton } from './ScrollToLatestButton';
|
||||||
|
|
||||||
|
describe('ScrollToLatestButton', () => {
|
||||||
|
it('stays in the centered message column and only the button accepts clicks', async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<ScrollToLatestButton newMessageCount={0} onClick={onClick} />
|
||||||
|
</I18nextProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const positioner = screen.getByTestId('scroll-to-latest-positioner');
|
||||||
|
expect(positioner).toHaveClass('absolute', 'inset-x-24', 'bottom-3', 'pointer-events-none', 'z-30');
|
||||||
|
expect(positioner).not.toHaveClass('left-3', 'right-24');
|
||||||
|
expect(positioner.firstElementChild).toHaveClass('mx-auto', 'max-w-3xl', 'justify-center');
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button).toHaveClass('pointer-events-auto', 'min-w-0', 'max-w-full');
|
||||||
|
await userEvent.click(button);
|
||||||
|
expect(onClick).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the new-message count without changing its positioner', () => {
|
||||||
|
render(
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<ScrollToLatestButton newMessageCount={3} onClick={() => {}} />
|
||||||
|
</I18nextProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('scroll-to-latest-positioner')).toHaveClass('absolute', 'inset-x-24', 'bottom-3');
|
||||||
|
expect(screen.getByText(/3/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
35
ui/src/components/chat/ScrollToLatestButton.tsx
Normal file
35
ui/src/components/chat/ScrollToLatestButton.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
export function ScrollToLatestButton({
|
||||||
|
newMessageCount,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
newMessageCount: number;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation('chat');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="scroll-to-latest-positioner"
|
||||||
|
className="pointer-events-none absolute inset-x-24 bottom-3 z-30"
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex max-w-3xl justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="pointer-events-auto flex min-w-0 max-w-full items-center gap-1.5 rounded-full border border-slate-200 bg-canvas px-3 py-1.5 text-xs text-slate-600 shadow-md transition-colors hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M4 6l4 4 4-4" />
|
||||||
|
</svg>
|
||||||
|
{newMessageCount > 0 ? (
|
||||||
|
<span className="min-w-0 truncate font-medium text-blue-600">{t('pane.newMessages', { count: newMessageCount })}</span>
|
||||||
|
) : (
|
||||||
|
<span className="min-w-0 truncate">{t('pane.toLatest')}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
ui/src/components/chat/WaterContextGauge.test.tsx
Normal file
37
ui/src/components/chat/WaterContextGauge.test.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import '../../test/dom-setup';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { WaterContextGauge, getWaterTone } from './WaterContextGauge';
|
||||||
|
|
||||||
|
describe('WaterContextGauge', () => {
|
||||||
|
it('uses the four context pressure tones', () => {
|
||||||
|
expect(getWaterTone(0.2)).toBe('water-context--low');
|
||||||
|
expect(getWaterTone(0.5)).toBe('water-context--mid');
|
||||||
|
expect(getWaterTone(0.7)).toBe('water-context--high');
|
||||||
|
expect(getWaterTone(0.9)).toBe('water-context--critical');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills to the clamped token percentage', () => {
|
||||||
|
const { container } = render(<WaterContextGauge variant="fill" promptTokens={750} limitTokens={1000} />);
|
||||||
|
const fill = screen.getByTestId('water-context-fill');
|
||||||
|
expect(fill).toHaveStyle({ '--water-level': '75%' });
|
||||||
|
expect(fill).toHaveClass('water-context');
|
||||||
|
expect(container.querySelector('.water-context__ambient-edge')).not.toBeInTheDocument();
|
||||||
|
expect(container.querySelector('.water-context__meter')).not.toBeInTheDocument();
|
||||||
|
expect(container.querySelector('.water-context__wave')).not.toBeInTheDocument();
|
||||||
|
expect(container.querySelector('.water-context__bubbles')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the exact token count in label mode', () => {
|
||||||
|
render(<WaterContextGauge variant="label" promptTokens={234} limitTokens={1000} />);
|
||||||
|
expect(screen.getByText('234 / 1,000')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render water before the first token measurement', () => {
|
||||||
|
const { rerender } = render(<WaterContextGauge variant="fill" limitTokens={1000} />);
|
||||||
|
expect(screen.queryByTestId('water-context-fill')).not.toBeInTheDocument();
|
||||||
|
rerender(<WaterContextGauge variant="label" limitTokens={1000} />);
|
||||||
|
expect(screen.getByText(/context\.awaiting|Awaiting first LLM call/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
53
ui/src/components/chat/WaterContextGauge.tsx
Normal file
53
ui/src/components/chat/WaterContextGauge.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface WaterContextGaugeProps {
|
||||||
|
promptTokens?: number | null;
|
||||||
|
limitTokens?: number | null;
|
||||||
|
variant: 'fill' | 'label';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number): string {
|
||||||
|
return value.toLocaleString('en-US');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWaterTone(ratio: number): string {
|
||||||
|
if (ratio >= 0.9) return 'water-context--critical';
|
||||||
|
if (ratio >= 0.7) return 'water-context--high';
|
||||||
|
if (ratio >= 0.5) return 'water-context--mid';
|
||||||
|
return 'water-context--low';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WaterContextGauge({ promptTokens, limitTokens, variant }: WaterContextGaugeProps) {
|
||||||
|
const { t } = useTranslation('detail');
|
||||||
|
if (!limitTokens || limitTokens <= 0) return null;
|
||||||
|
|
||||||
|
const awaiting = typeof promptTokens !== 'number';
|
||||||
|
const tokens = awaiting ? 0 : promptTokens;
|
||||||
|
const ratio = Math.min(1, Math.max(0, tokens / limitTokens));
|
||||||
|
const percent = Math.round(ratio * 100);
|
||||||
|
const remaining = Math.max(0, limitTokens - tokens);
|
||||||
|
const style = { '--water-level': `${percent}%` } as CSSProperties;
|
||||||
|
|
||||||
|
if (variant === 'fill') {
|
||||||
|
if (awaiting) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
data-testid="water-context-fill"
|
||||||
|
className={`water-context ${getWaterTone(ratio)}`}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="relative z-10 shrink-0 text-2xs tabular-nums text-slate-500 dark:text-slate-300"
|
||||||
|
title={`${formatNumber(tokens)} / ${formatNumber(limitTokens)} tokens`}
|
||||||
|
aria-label={t('context.ariaLabel', { remaining: formatNumber(remaining), percent })}
|
||||||
|
>
|
||||||
|
{awaiting ? t('context.awaiting') : `${formatNumber(tokens)} / ${formatNumber(limitTokens)}`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
ui/src/components/chat/modelSelectorPosition.test.ts
Normal file
76
ui/src/components/chat/modelSelectorPosition.test.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { computeModelSelectorPosition } from './modelSelectorPosition';
|
||||||
|
|
||||||
|
describe('computeModelSelectorPosition', () => {
|
||||||
|
it('places the panel above the trigger when it fits', () => {
|
||||||
|
expect(computeModelSelectorPosition({
|
||||||
|
trigger: { left: 100, right: 200, top: 500, bottom: 530 },
|
||||||
|
panelWidth: 260,
|
||||||
|
panelHeight: 200,
|
||||||
|
viewportWidth: 1000,
|
||||||
|
viewportHeight: 800,
|
||||||
|
})).toEqual({ left: 100, top: 294, width: 260, maxHeight: 482, placement: 'top' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flips below and clamps to the viewport edges', () => {
|
||||||
|
expect(computeModelSelectorPosition({
|
||||||
|
trigger: { left: 290, right: 310, top: 20, bottom: 50 },
|
||||||
|
panelWidth: 260,
|
||||||
|
panelHeight: 180,
|
||||||
|
viewportWidth: 320,
|
||||||
|
viewportHeight: 400,
|
||||||
|
})).toEqual({ left: 48, top: 56, width: 260, maxHeight: 332, placement: 'bottom' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never returns a negative width for an extremely narrow viewport', () => {
|
||||||
|
const position = computeModelSelectorPosition({
|
||||||
|
trigger: { left: 0, right: 10, top: 50, bottom: 70 },
|
||||||
|
panelWidth: 260,
|
||||||
|
panelHeight: 100,
|
||||||
|
viewportWidth: 20,
|
||||||
|
viewportHeight: 200,
|
||||||
|
});
|
||||||
|
expect(position.width).toBe(0);
|
||||||
|
expect(position.left).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits an oversized panel to the viewport height', () => {
|
||||||
|
const position = computeModelSelectorPosition({
|
||||||
|
trigger: { left: 20, right: 80, top: 300, bottom: 330 },
|
||||||
|
panelWidth: 260,
|
||||||
|
panelHeight: 900,
|
||||||
|
viewportWidth: 600,
|
||||||
|
viewportHeight: 500,
|
||||||
|
});
|
||||||
|
expect(position.maxHeight).toBe(282);
|
||||||
|
expect(position.top).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the larger side without covering the trigger when neither side fits', () => {
|
||||||
|
const position = computeModelSelectorPosition({
|
||||||
|
trigger: { left: 100, right: 180, top: 200, bottom: 230 },
|
||||||
|
panelWidth: 260,
|
||||||
|
panelHeight: 300,
|
||||||
|
viewportWidth: 500,
|
||||||
|
viewportHeight: 500,
|
||||||
|
});
|
||||||
|
expect(position.placement).toBe('bottom');
|
||||||
|
expect(position.top).toBe(236);
|
||||||
|
expect(position.maxHeight).toBe(252);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects a visual viewport offset', () => {
|
||||||
|
const position = computeModelSelectorPosition({
|
||||||
|
trigger: { left: 150, right: 230, top: 250, bottom: 280 },
|
||||||
|
panelWidth: 260,
|
||||||
|
panelHeight: 160,
|
||||||
|
viewportWidth: 320,
|
||||||
|
viewportHeight: 300,
|
||||||
|
viewportLeft: 100,
|
||||||
|
viewportTop: 200,
|
||||||
|
});
|
||||||
|
expect(position.left).toBe(148);
|
||||||
|
expect(position.top).toBe(286);
|
||||||
|
expect(position.maxHeight).toBe(202);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
ui/src/components/chat/modelSelectorPosition.ts
Normal file
46
ui/src/components/chat/modelSelectorPosition.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
export interface ModelSelectorPosition {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
maxHeight: number;
|
||||||
|
placement: 'top' | 'bottom';
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEWPORT_MARGIN = 12;
|
||||||
|
const TRIGGER_GAP = 6;
|
||||||
|
|
||||||
|
export function computeModelSelectorPosition({
|
||||||
|
trigger,
|
||||||
|
panelWidth,
|
||||||
|
panelHeight,
|
||||||
|
viewportWidth,
|
||||||
|
viewportHeight,
|
||||||
|
viewportLeft = 0,
|
||||||
|
viewportTop = 0,
|
||||||
|
}: {
|
||||||
|
trigger: Pick<DOMRect, 'left' | 'top' | 'right' | 'bottom'>;
|
||||||
|
panelWidth: number;
|
||||||
|
panelHeight: number;
|
||||||
|
viewportWidth: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
viewportLeft?: number;
|
||||||
|
viewportTop?: number;
|
||||||
|
}): ModelSelectorPosition {
|
||||||
|
const viewportRight = viewportLeft + viewportWidth;
|
||||||
|
const viewportBottom = viewportTop + viewportHeight;
|
||||||
|
const availableAbove = Math.max(0, trigger.top - TRIGGER_GAP - viewportTop - VIEWPORT_MARGIN);
|
||||||
|
const availableBelow = Math.max(0, viewportBottom - VIEWPORT_MARGIN - trigger.bottom - TRIGGER_GAP);
|
||||||
|
const placement = panelHeight <= availableAbove || availableAbove >= availableBelow ? 'top' : 'bottom';
|
||||||
|
const maxHeight = placement === 'top' ? availableAbove : availableBelow;
|
||||||
|
const effectiveHeight = Math.min(panelHeight, maxHeight);
|
||||||
|
const top = placement === 'top'
|
||||||
|
? trigger.top - TRIGGER_GAP - effectiveHeight
|
||||||
|
: trigger.bottom + TRIGGER_GAP;
|
||||||
|
const effectiveWidth = Math.max(0, Math.min(panelWidth, viewportWidth - VIEWPORT_MARGIN * 2));
|
||||||
|
const left = Math.min(
|
||||||
|
Math.max(viewportLeft + VIEWPORT_MARGIN, trigger.left),
|
||||||
|
Math.max(viewportLeft + VIEWPORT_MARGIN, viewportRight - VIEWPORT_MARGIN - effectiveWidth),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { left, top, width: effectiveWidth, maxHeight, placement };
|
||||||
|
}
|
||||||
48
ui/src/components/chat/movementHistory.test.ts
Normal file
48
ui/src/components/chat/movementHistory.test.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { LocalTaskComment, MovementHistoryEvent } from '../../api';
|
||||||
|
import { assignRetryEvents, buildMovementRailItems, parseJobRetry } from './movementHistory';
|
||||||
|
|
||||||
|
const history = (eventId: string, kind: MovementHistoryEvent['kind'], movement: string, ts: string, seq: number): MovementHistoryEvent => ({
|
||||||
|
eventId, kind, movement, ts, seq, line: seq, runId: 'run-1', iteration: null, payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const comment = (id: number, kind: LocalTaskComment['kind'], body: string, author = 'user'): LocalTaskComment => ({
|
||||||
|
id, taskId: 1, kind, body, author, attachments: [], createdAt: `2026-07-12T00:00:${id}.000Z`, injectedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildMovementRailItems', () => {
|
||||||
|
it('keeps repeated movements and marks a backwards transition', () => {
|
||||||
|
const events = [
|
||||||
|
history('s1', 'movement_start', 'implement', '2026-07-12T00:00:01.000Z', 1),
|
||||||
|
history('s2', 'movement_start', 'verify', '2026-07-12T00:00:02.000Z', 2),
|
||||||
|
history('s3', 'movement_start', 'implement', '2026-07-12T00:00:03.000Z', 3),
|
||||||
|
];
|
||||||
|
const items = buildMovementRailItems(events, [], ['investigate', 'implement', 'verify']);
|
||||||
|
expect(items.filter(item => item.type === 'movement').map(item => item.name)).toEqual(['implement', 'verify', 'implement']);
|
||||||
|
expect(items.some(item => item.type === 'return' && item.name === 'implement')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches retries to the same run occurrence and leaves unfinished retries unassigned', () => {
|
||||||
|
const events = [
|
||||||
|
history('s1', 'movement_start', 'implement', '2026-07-12T00:00:01.000Z', 1),
|
||||||
|
{ ...history('r1', 'llm_call_retry', 'implement', '2026-07-12T00:00:02.000Z', 2), payload: { attempt: 2 } },
|
||||||
|
history('c1', 'movement_complete', 'implement', '2026-07-12T00:00:03.000Z', 3),
|
||||||
|
{ ...history('s2', 'movement_start', 'implement', '2026-07-12T00:00:04.000Z', 1), runId: 'run-2' },
|
||||||
|
{ ...history('r2', 'llm_call_retry', 'implement', '2026-07-12T00:00:05.000Z', 2), runId: 'run-2' },
|
||||||
|
];
|
||||||
|
const assigned = assignRetryEvents(events, [{ id: 10, movement: 'implement' }]);
|
||||||
|
expect(assigned.byCompletionId.get(10)?.map(item => item.eventId)).toEqual(['r1']);
|
||||||
|
expect(assigned.unassigned.map(item => item.eventId)).toEqual(['r2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds user input and job retry while excluding system comments', () => {
|
||||||
|
const retry = JSON.stringify({ type: 'job_retry', disposition: 'retry', attempt: 1, nextAttempt: 2, maxAttempts: 3 });
|
||||||
|
const items = buildMovementRailItems([], [
|
||||||
|
comment(1, 'request', 'Initial request'),
|
||||||
|
comment(2, 'progress', retry, 'system'),
|
||||||
|
comment(3, 'comment', 'internal', 'system'),
|
||||||
|
], ['implement']);
|
||||||
|
expect(items.map(item => item.type)).toEqual(['user_input', 'job_retry']);
|
||||||
|
expect(parseJobRetry(retry)?.nextAttempt).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
151
ui/src/components/chat/movementHistory.ts
Normal file
151
ui/src/components/chat/movementHistory.ts
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import type { LocalTaskComment, MovementHistoryEvent } from '../../api';
|
||||||
|
|
||||||
|
export type MovementRailItem =
|
||||||
|
| { id: string; type: 'movement'; name: string; status: 'completed' | 'active'; ts: string; targetId?: string }
|
||||||
|
| { id: string; type: 'return'; name: string; ts: string; targetId?: string }
|
||||||
|
| { id: string; type: 'llm_retry'; name: string; ts: string; targetId: string; detail: string }
|
||||||
|
| { id: string; type: 'job_retry'; name: string; ts: string; targetId: string; detail: string }
|
||||||
|
| { id: string; type: 'user_input'; name: string; ts: string; targetId: string; detail: string };
|
||||||
|
|
||||||
|
interface JobRetryData {
|
||||||
|
type: 'job_retry';
|
||||||
|
disposition: 'retry' | 'requeued_unhealthy';
|
||||||
|
attempt: number;
|
||||||
|
nextAttempt: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseJobRetry = (body: string): JobRetryData | null => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body) as Partial<JobRetryData>;
|
||||||
|
if (data.type !== 'job_retry'
|
||||||
|
|| (data.disposition !== 'retry' && data.disposition !== 'requeued_unhealthy')
|
||||||
|
|| typeof data.attempt !== 'number' || !Number.isFinite(data.attempt)
|
||||||
|
|| typeof data.nextAttempt !== 'number' || !Number.isFinite(data.nextAttempt)
|
||||||
|
|| typeof data.maxAttempts !== 'number' || !Number.isFinite(data.maxAttempts)) return null;
|
||||||
|
return data as JobRetryData;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const userComment = (comment: LocalTaskComment): boolean =>
|
||||||
|
['request', 'comment', 'interjection'].includes(comment.kind)
|
||||||
|
&& comment.author !== 'agent'
|
||||||
|
&& comment.author !== 'system';
|
||||||
|
|
||||||
|
export const buildMovementRailItems = (
|
||||||
|
events: MovementHistoryEvent[],
|
||||||
|
comments: LocalTaskComment[],
|
||||||
|
movementNames: string[],
|
||||||
|
): MovementRailItem[] => {
|
||||||
|
const order = new Map(movementNames.map((name, index) => [name, index]));
|
||||||
|
const items: MovementRailItem[] = [];
|
||||||
|
let previousMovement: string | null = null;
|
||||||
|
const completionTargets = new Map<string, string[]>();
|
||||||
|
for (const comment of comments) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(comment.body) as { movement?: unknown; durationMs?: unknown };
|
||||||
|
if (comment.kind !== 'progress' || typeof data.movement !== 'string' || typeof data.durationMs !== 'number') continue;
|
||||||
|
const targets = completionTargets.get(data.movement) ?? [];
|
||||||
|
targets.push(`movement-completion-${comment.id}`);
|
||||||
|
completionTargets.set(data.movement, targets);
|
||||||
|
} catch { /* not a movement completion */ }
|
||||||
|
}
|
||||||
|
const occurrence = new Map<string, number>();
|
||||||
|
const openMovements = new Map<string, number[]>();
|
||||||
|
|
||||||
|
const sortedEvents = [...events].sort((a, b) =>
|
||||||
|
a.ts.localeCompare(b.ts) || (a.runId === b.runId ? a.seq - b.seq : a.line - b.line));
|
||||||
|
for (const event of sortedEvents) {
|
||||||
|
const name = event.movement ?? 'unknown';
|
||||||
|
if (event.kind === 'movement_start') {
|
||||||
|
const index = occurrence.get(name) ?? 0;
|
||||||
|
occurrence.set(name, index + 1);
|
||||||
|
const movementTarget = completionTargets.get(name)?.[index];
|
||||||
|
const previousIndex = previousMovement ? order.get(previousMovement) : undefined;
|
||||||
|
const nextIndex = order.get(name);
|
||||||
|
if (previousIndex !== undefined && nextIndex !== undefined && nextIndex < previousIndex) {
|
||||||
|
items.push({ id: `return-${event.eventId}`, type: 'return', name, ts: event.ts, targetId: movementTarget });
|
||||||
|
}
|
||||||
|
const itemIndex = items.length;
|
||||||
|
items.push({ id: event.eventId, type: 'movement', name, status: 'active', ts: event.ts, targetId: movementTarget });
|
||||||
|
const runMovement = `${event.runId}:${name}`;
|
||||||
|
const open = openMovements.get(runMovement) ?? [];
|
||||||
|
open.push(itemIndex);
|
||||||
|
openMovements.set(runMovement, open);
|
||||||
|
previousMovement = name;
|
||||||
|
} else if (event.kind === 'movement_complete') {
|
||||||
|
const index = Math.max(0, (occurrence.get(name) ?? 1) - 1);
|
||||||
|
const open = openMovements.get(`${event.runId}:${name}`);
|
||||||
|
const itemIndex = open?.shift();
|
||||||
|
if (itemIndex !== undefined) {
|
||||||
|
const started = items[itemIndex];
|
||||||
|
if (started?.type === 'movement') {
|
||||||
|
items[itemIndex] = { ...started, status: 'completed', targetId: completionTargets.get(name)?.[index] ?? started.targetId };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items.push({ id: event.eventId, type: 'movement', name, status: 'completed', ts: event.ts, targetId: completionTargets.get(name)?.[index] });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const attempt = event.payload.attempt ?? '?';
|
||||||
|
const max = event.payload.maxAttempts ?? '?';
|
||||||
|
const error = event.payload.errorClass ?? (event.payload.httpStatus ? `HTTP ${event.payload.httpStatus}` : 'LLM error');
|
||||||
|
items.push({ id: event.eventId, type: 'llm_retry', name, ts: event.ts, targetId: `trace-event-${event.eventId}`, detail: `${attempt}/${max} · ${error}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const comment of comments) {
|
||||||
|
const retry = comment.kind === 'progress' && comment.author === 'system' ? parseJobRetry(comment.body) : null;
|
||||||
|
if (retry) {
|
||||||
|
const detail = retry.disposition === 'requeued_unhealthy'
|
||||||
|
? 'Worker connection retry'
|
||||||
|
: `${retry.nextAttempt}/${retry.maxAttempts}`;
|
||||||
|
items.push({ id: `comment-${comment.id}`, type: 'job_retry', name: 'Retry', ts: comment.createdAt, targetId: `comment-${comment.id}`, detail });
|
||||||
|
} else if (userComment(comment)) {
|
||||||
|
items.push({
|
||||||
|
id: `comment-${comment.id}`,
|
||||||
|
type: 'user_input',
|
||||||
|
name: comment.kind === 'request' ? 'Request' : 'Comment',
|
||||||
|
ts: comment.createdAt,
|
||||||
|
targetId: `comment-${comment.id}`,
|
||||||
|
detail: comment.body.replace(/\s+/g, ' ').trim().slice(0, 80),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items.sort((a, b) => a.ts.localeCompare(b.ts));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assignRetryEvents = (
|
||||||
|
events: MovementHistoryEvent[],
|
||||||
|
completions: Array<{ id: number; movement: string }>,
|
||||||
|
): { byCompletionId: Map<number, MovementHistoryEvent[]>; unassigned: MovementHistoryEvent[] } => {
|
||||||
|
const targets = new Map<string, number[]>();
|
||||||
|
for (const completion of completions) {
|
||||||
|
const ids = targets.get(completion.movement) ?? [];
|
||||||
|
ids.push(completion.id);
|
||||||
|
targets.set(completion.movement, ids);
|
||||||
|
}
|
||||||
|
const byCompletionId = new Map<number, MovementHistoryEvent[]>();
|
||||||
|
const active = new Map<string, MovementHistoryEvent[]>();
|
||||||
|
const unassigned: MovementHistoryEvent[] = [];
|
||||||
|
const sorted = [...events].sort((a, b) => a.ts.localeCompare(b.ts) || a.line - b.line);
|
||||||
|
for (const event of sorted) {
|
||||||
|
const key = `${event.runId}:${event.movement ?? 'unknown'}`;
|
||||||
|
if (event.kind === 'movement_start') {
|
||||||
|
active.set(key, []);
|
||||||
|
} else if (event.kind === 'llm_call_retry') {
|
||||||
|
const retries = active.get(key);
|
||||||
|
if (retries) retries.push(event);
|
||||||
|
else unassigned.push(event);
|
||||||
|
} else if (event.kind === 'movement_complete') {
|
||||||
|
const completionId = targets.get(event.movement ?? 'unknown')?.shift();
|
||||||
|
const retries = active.get(key) ?? [];
|
||||||
|
if (completionId !== undefined) byCompletionId.set(completionId, retries);
|
||||||
|
else unassigned.push(...retries);
|
||||||
|
active.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const retries of active.values()) unassigned.push(...retries);
|
||||||
|
return { byCompletionId, unassigned };
|
||||||
|
};
|
||||||
@ -58,4 +58,16 @@ describe('CreateTaskDialog outside-click safety', () => {
|
|||||||
await user.click(screen.getByLabelText(/閉じる|close/i));
|
await user.click(screen.getByLabelText(/閉じる|close/i));
|
||||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('指定したコンテナ内ではオーバーレイなしのインラインフォームになる', async () => {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderWithProviders(<CreateTaskDialog inlineContainer={host} onClose={onClose} onSubmit={vi.fn(async () => {})} />);
|
||||||
|
await waitFor(() => expect(host.querySelector('[data-testid="create-task-body"]')).not.toBeNull());
|
||||||
|
expect(document.querySelector('.fixed.inset-0')).toBeNull();
|
||||||
|
await userEvent.setup().click(screen.getByText(/キャンセル|cancel/i));
|
||||||
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
158
ui/src/components/create/CreateTaskDialog.llm-selection.test.tsx
Normal file
158
ui/src/components/create/CreateTaskDialog.llm-selection.test.tsx
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import '../../test/dom-setup';
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { renderWithProviders } from '../../test/render-helpers';
|
||||||
|
import { CreateTaskDialog } from './CreateTaskDialog';
|
||||||
|
import type { CreateLocalTaskInput } from '../../api';
|
||||||
|
|
||||||
|
const WORKERS = [
|
||||||
|
{ id: 'w1', model: 'm1', roles: ['auto'], reasoningEfforts: ['low', 'high'], vlm: false, enabled: true },
|
||||||
|
{ id: 'w2', model: 'm2', roles: ['auto'], reasoningEfforts: [], vlm: false, enabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
// CreateTaskDialog fetches auth/me・pieces・spaces・orgs・browser-session-profiles・
|
||||||
|
// mcp/connections 等。全部 404 で落としても各 hook は空データで動く(retry:false)。
|
||||||
|
// /api/llm/workers だけ実データを返す。
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes('/api/llm/workers')) {
|
||||||
|
return new Response(JSON.stringify({ workers: WORKERS }), { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response('{}', { status: 404 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderDialog(props: Partial<Parameters<typeof CreateTaskDialog>[0]> = {}) {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const onSubmit = vi.fn(async () => {});
|
||||||
|
const utils = renderWithProviders(
|
||||||
|
<CreateTaskDialog onClose={onClose} onSubmit={onSubmit} {...props} />,
|
||||||
|
);
|
||||||
|
return { onClose, onSubmit, ...utils };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAdvanced(user: ReturnType<typeof userEvent.setup>) {
|
||||||
|
await user.click(screen.getByRole('button', { name: /詳細設定を開く|show advanced settings/i }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CreateTaskDialog — LLM worker override + reasoning effort', () => {
|
||||||
|
it('effort select is disabled until a worker is chosen, then shows that worker\'s efforts', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderDialog();
|
||||||
|
await openAdvanced(user);
|
||||||
|
|
||||||
|
const effortSelect = await screen.findByTestId('create-task-llm-effort');
|
||||||
|
expect(effortSelect).toBeDisabled();
|
||||||
|
|
||||||
|
const workerSelect = screen.getByTestId('create-task-llm-worker');
|
||||||
|
await waitFor(() => expect(screen.getByText('m1 @ w1')).toBeInTheDocument());
|
||||||
|
await user.selectOptions(workerSelect, 'w1');
|
||||||
|
|
||||||
|
expect(effortSelect).not.toBeDisabled();
|
||||||
|
const optionLabels = Array.from((effortSelect as HTMLSelectElement).options).map(o => o.value);
|
||||||
|
expect(optionLabels).toEqual(['', 'low', 'high']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switching workers resets the effort (w2 has no declared efforts)', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderDialog();
|
||||||
|
await openAdvanced(user);
|
||||||
|
|
||||||
|
const workerSelect = screen.getByTestId('create-task-llm-worker');
|
||||||
|
const effortSelect = screen.getByTestId('create-task-llm-effort');
|
||||||
|
await waitFor(() => expect(screen.getByText('m1 @ w1')).toBeInTheDocument());
|
||||||
|
|
||||||
|
await user.selectOptions(workerSelect, 'w1');
|
||||||
|
await user.selectOptions(effortSelect, 'high');
|
||||||
|
expect((effortSelect as HTMLSelectElement).value).toBe('high');
|
||||||
|
|
||||||
|
await user.selectOptions(workerSelect, 'w2');
|
||||||
|
expect((effortSelect as HTMLSelectElement).value).toBe('');
|
||||||
|
const optionLabels = Array.from((effortSelect as HTMLSelectElement).options).map(o => o.value);
|
||||||
|
expect(optionLabels).toEqual(['']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearing the worker back to auto resets both llmWorkerId and llmEffort to null in the submitted payload', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onSubmit } = renderDialog();
|
||||||
|
await openAdvanced(user);
|
||||||
|
|
||||||
|
const workerSelect = screen.getByTestId('create-task-llm-worker');
|
||||||
|
const effortSelect = screen.getByTestId('create-task-llm-effort');
|
||||||
|
await waitFor(() => expect(screen.getByText('m1 @ w1')).toBeInTheDocument());
|
||||||
|
|
||||||
|
await user.selectOptions(workerSelect, 'w1');
|
||||||
|
await user.selectOptions(effortSelect, 'high');
|
||||||
|
// back to auto
|
||||||
|
await user.selectOptions(workerSelect, '');
|
||||||
|
|
||||||
|
await user.type(screen.getByTestId('create-task-body'), '本文');
|
||||||
|
await user.click(screen.getByTestId('create-task-submit'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||||
|
const [submittedInput] = onSubmit.mock.calls[0] as unknown as [CreateLocalTaskInput, unknown];
|
||||||
|
expect(submittedInput.llmWorkerId).toBeNull();
|
||||||
|
expect(submittedInput.llmEffort).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a chosen worker sends llmWorkerId/llmEffort, disables the profile select, and shows the override hint', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onSubmit } = renderDialog();
|
||||||
|
await openAdvanced(user);
|
||||||
|
|
||||||
|
const workerSelect = screen.getByTestId('create-task-llm-worker');
|
||||||
|
const effortSelect = screen.getByTestId('create-task-llm-effort');
|
||||||
|
await waitFor(() => expect(screen.getByText('m1 @ w1')).toBeInTheDocument());
|
||||||
|
|
||||||
|
await user.selectOptions(workerSelect, 'w1');
|
||||||
|
await user.selectOptions(effortSelect, 'low');
|
||||||
|
|
||||||
|
const profileSelect = screen.getByDisplayValue('auto') as HTMLSelectElement;
|
||||||
|
expect(profileSelect).toBeDisabled();
|
||||||
|
expect(screen.getByText(/ワーカーを指定するとプロファイルは使われません|Pinning a worker overrides the profile setting/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.type(screen.getByTestId('create-task-body'), '本文');
|
||||||
|
await user.click(screen.getByTestId('create-task-submit'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||||
|
const [submittedInput] = onSubmit.mock.calls[0] as unknown as [CreateLocalTaskInput, unknown];
|
||||||
|
expect(submittedInput.llmWorkerId).toBe('w1');
|
||||||
|
expect(submittedInput.llmEffort).toBe('low');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fix C (P2, codex round3): scheduled-task POST doesn't send llmWorkerId/
|
||||||
|
// llmEffort and the scheduler doesn't persist/inherit them, so a chosen
|
||||||
|
// worker/effort was silently dropped on scheduled runs. Disable both
|
||||||
|
// selects (and clear any prior selection) once "定期実行" is toggled on,
|
||||||
|
// and surface a hint explaining why.
|
||||||
|
it('toggling scheduled mode disables the worker+effort selects, clears a prior selection, and shows the hint', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderDialog();
|
||||||
|
await openAdvanced(user);
|
||||||
|
|
||||||
|
const workerSelect = screen.getByTestId('create-task-llm-worker') as HTMLSelectElement;
|
||||||
|
const effortSelect = screen.getByTestId('create-task-llm-effort') as HTMLSelectElement;
|
||||||
|
await waitFor(() => expect(screen.getByText('m1 @ w1')).toBeInTheDocument());
|
||||||
|
|
||||||
|
await user.selectOptions(workerSelect, 'w1');
|
||||||
|
await user.selectOptions(effortSelect, 'low');
|
||||||
|
expect(workerSelect.value).toBe('w1');
|
||||||
|
expect(effortSelect.value).toBe('low');
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('create-task-llm-scheduled-hint')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: /定期実行|Run on a schedule/i }));
|
||||||
|
|
||||||
|
expect(workerSelect).toBeDisabled();
|
||||||
|
expect(effortSelect).toBeDisabled();
|
||||||
|
expect(workerSelect.value).toBe('');
|
||||||
|
expect(effortSelect.value).toBe('');
|
||||||
|
expect(screen.getByTestId('create-task-llm-scheduled-hint')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -2,10 +2,9 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import * as Dialog from '@radix-ui/react-dialog';
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { CreateLocalTaskInput, fetchMyOrgs, Visibility, listBrowserSessionProfiles } from '../../api';
|
import { CreateLocalTaskInput, fetchMyOrgs, Visibility, listBrowserSessionProfiles, fetchLlmWorkers } from '../../api';
|
||||||
import { AttachmentDropzone } from './AttachmentDropzone';
|
import { CreateTaskDialogAdvancedSection } from './CreateTaskDialogAdvancedSection';
|
||||||
import { PromptCoachPanel } from './PromptCoachPanel';
|
import { CreateTaskDialogCoreSection } from './CreateTaskDialogCoreSection';
|
||||||
import { ScheduleFields } from './ScheduleFields';
|
|
||||||
import { usePieceList } from '../../hooks/usePieces';
|
import { usePieceList } from '../../hooks/usePieces';
|
||||||
import { useSpaces } from '../../hooks/useSpaces';
|
import { useSpaces } from '../../hooks/useSpaces';
|
||||||
import { sortSpacesForRail } from '../../lib/spaceSort';
|
import { sortSpacesForRail } from '../../lib/spaceSort';
|
||||||
@ -31,9 +30,10 @@ interface CreateTaskDialogProps {
|
|||||||
* 未指定(グローバルのタスク一覧から開いた)時は個人スペースが既定。
|
* 未指定(グローバルのタスク一覧から開いた)時は個人スペースが既定。
|
||||||
*/
|
*/
|
||||||
initialSpaceId?: string;
|
initialSpaceId?: string;
|
||||||
|
/** Optional in-page portal target. When set, the form is non-modal. */
|
||||||
|
inlineContainer?: HTMLElement | null;
|
||||||
}
|
}
|
||||||
|
export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody, placeholder, initialSpaceId, inlineContainer }: CreateTaskDialogProps) {
|
||||||
export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody, placeholder, initialSpaceId }: CreateTaskDialogProps) {
|
|
||||||
const { t } = useTranslation('create');
|
const { t } = useTranslation('create');
|
||||||
const { data: pieces } = usePieceList();
|
const { data: pieces } = usePieceList();
|
||||||
const { data: spacesData } = useSpaces();
|
const { data: spacesData } = useSpaces();
|
||||||
@ -46,6 +46,8 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
|||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
const activeSessionProfiles = sessionProfiles.filter(p => p.status === 'active');
|
const activeSessionProfiles = sessionProfiles.filter(p => p.status === 'active');
|
||||||
|
const { data: llmWorkers = [] } = useQuery({ queryKey: ['llm-workers'], queryFn: fetchLlmWorkers });
|
||||||
|
const enabledLlmWorkers = llmWorkers.filter(w => w.enabled !== false);
|
||||||
interface ConnectionRow { serverId: string; serverName: string; connected: boolean }
|
interface ConnectionRow { serverId: string; serverName: string; connected: boolean }
|
||||||
const { data: connections } = useQuery({
|
const { data: connections } = useQuery({
|
||||||
queryKey: ['mcp-connections'],
|
queryKey: ['mcp-connections'],
|
||||||
@ -85,6 +87,8 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
|||||||
askPolicy: 'low',
|
askPolicy: 'low',
|
||||||
priority: 'medium',
|
priority: 'medium',
|
||||||
workspaceMode: 'persistent',
|
workspaceMode: 'persistent',
|
||||||
|
llmWorkerId: null,
|
||||||
|
llmEffort: null,
|
||||||
});
|
});
|
||||||
// スペース起点で開いた場合は固定。それ以外はユーザーが任意で選択(既定=個人)。
|
// スペース起点で開いた場合は固定。それ以外はユーザーが任意で選択(既定=個人)。
|
||||||
const [selectedSpaceId, setSelectedSpaceId] = useState<string | undefined>(initialSpaceId);
|
const [selectedSpaceId, setSelectedSpaceId] = useState<string | undefined>(initialSpaceId);
|
||||||
@ -92,8 +96,6 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
|||||||
const fixedSpace = initialSpaceId ? sortedSpaces.find(s => s.id === initialSpaceId) : undefined;
|
const fixedSpace = initialSpaceId ? sortedSpaces.find(s => s.id === initialSpaceId) : undefined;
|
||||||
const [attachments, setAttachments] = useState<Array<{ name: string; contentBase64: string }>>([]);
|
const [attachments, setAttachments] = useState<Array<{ name: string; contentBase64: string }>>([]);
|
||||||
const [browserSessionProfileId, setBrowserSessionProfileId] = useState<number | null>(null);
|
const [browserSessionProfileId, setBrowserSessionProfileId] = useState<number | null>(null);
|
||||||
const [mcpDisabled, setMcpDisabled] = useState(false);
|
|
||||||
const [skillsDisabled, setSkillsDisabled] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
@ -169,9 +171,6 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
|||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const options: Record<string, boolean> = {};
|
|
||||||
if (mcpDisabled) options.mcpDisabled = true;
|
|
||||||
if (skillsDisabled) options.skillsDisabled = true;
|
|
||||||
const submitForm = {
|
const submitForm = {
|
||||||
...form,
|
...form,
|
||||||
// initialPiece が指定されているヘルプアシスタント等は piece を固定。
|
// initialPiece が指定されているヘルプアシスタント等は piece を固定。
|
||||||
@ -184,7 +183,6 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
|||||||
browserSessionProfileId: browserSessionProfileId ?? undefined,
|
browserSessionProfileId: browserSessionProfileId ?? undefined,
|
||||||
// 固定スペースを最優先、無ければ選択値。未指定なら個人スペースに解決される。
|
// 固定スペースを最優先、無ければ選択値。未指定なら個人スペースに解決される。
|
||||||
spaceId: initialSpaceId ?? selectedSpaceId ?? undefined,
|
spaceId: initialSpaceId ?? selectedSpaceId ?? undefined,
|
||||||
...(Object.keys(options).length > 0 ? { options } : {}),
|
|
||||||
};
|
};
|
||||||
await onSubmit(submitForm, attachments);
|
await onSubmit(submitForm, attachments);
|
||||||
clearDraft();
|
clearDraft();
|
||||||
@ -199,12 +197,14 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
|||||||
const dirty = form.body.trim().length > 0 || attachments.length > 0;
|
const dirty = form.body.trim().length > 0 || attachments.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.Root open onOpenChange={(open) => { if (!open) onClose(); }}>
|
<Dialog.Root open modal={!inlineContainer} onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||||
<Dialog.Portal>
|
<Dialog.Portal container={inlineContainer ?? undefined}>
|
||||||
<Dialog.Overlay className="fixed inset-0 bg-slate-900/50 z-30" />
|
{!inlineContainer && <Dialog.Overlay className="fixed inset-0 bg-slate-900/50 z-30" />}
|
||||||
<Dialog.Content
|
<Dialog.Content
|
||||||
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-surface rounded-2xl shadow-2xl w-full overflow-auto z-40 focus:outline-none"
|
className={inlineContainer
|
||||||
style={{ maxWidth: 'min(860px, 92vw)', maxHeight: '88dvh' }}
|
? 'h-full w-full overflow-auto bg-surface focus:outline-none'
|
||||||
|
: 'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-surface rounded-2xl shadow-2xl w-full overflow-auto z-40 focus:outline-none'}
|
||||||
|
style={inlineContainer ? undefined : { maxWidth: 'min(860px, 92vw)', maxHeight: '88dvh' }}
|
||||||
onOpenAutoFocus={e => {
|
onOpenAutoFocus={e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
@ -234,318 +234,60 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
|||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<CreateTaskDialogCoreSection
|
||||||
{/* Textarea */}
|
initialPiece={initialPiece}
|
||||||
<div>
|
placeholder={placeholder}
|
||||||
<label className="block text-[13px] text-slate-600 mb-1.5">{t('body.label')}</label>
|
initialSpaceId={initialSpaceId}
|
||||||
<textarea
|
fixedSpace={fixedSpace}
|
||||||
autoFocus
|
selectedSpaceId={selectedSpaceId}
|
||||||
data-testid="create-task-body"
|
setSelectedSpaceId={setSelectedSpaceId}
|
||||||
value={form.body}
|
form={form}
|
||||||
onChange={e => {
|
setForm={setForm}
|
||||||
const body = e.target.value;
|
attachments={attachments}
|
||||||
setForm(prev => ({ ...prev, body }));
|
setAttachments={setAttachments}
|
||||||
saveDraft(body);
|
resolvedPieces={resolvedPieces}
|
||||||
}}
|
missingMcp={missingMcp}
|
||||||
onKeyDown={e => {
|
onSubmit={handleSubmit}
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
saveDraft={saveDraft}
|
||||||
e.preventDefault();
|
sortedSpaces={sortedSpaces}
|
||||||
void handleSubmit();
|
orgs={orgs}
|
||||||
}
|
visibility={visibility}
|
||||||
}}
|
/>
|
||||||
rows={8}
|
<CreateTaskDialogAdvancedSection
|
||||||
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:border-accent resize-y leading-relaxed"
|
form={form}
|
||||||
placeholder={placeholder ?? (initialPiece === 'help' ? t('body.placeholderHelp') : t('body.placeholder'))}
|
setForm={setForm}
|
||||||
/>
|
resolvedPieces={resolvedPieces}
|
||||||
</div>
|
activeSessionProfiles={activeSessionProfiles}
|
||||||
|
enabledLlmWorkers={enabledLlmWorkers}
|
||||||
{/* Workspace mode (永続/一時的) + スペース選択 */}
|
isScheduled={isScheduled}
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:gap-4">
|
setIsScheduled={setIsScheduled}
|
||||||
<div>
|
schedule={schedule}
|
||||||
<label className="block text-2xs text-slate-500 mb-1">{t('workspace.modeLabel')}</label>
|
setSchedule={setSchedule}
|
||||||
<div className="inline-flex rounded-lg border border-slate-200 p-0.5">
|
browserSessionProfileId={browserSessionProfileId}
|
||||||
{([['persistent', t('workspace.persistent')], ['ephemeral', t('workspace.ephemeral')]] as const).map(([mode, label]) => {
|
setBrowserSessionProfileId={setBrowserSessionProfileId}
|
||||||
const active = (form.workspaceMode ?? 'persistent') === mode;
|
visibility={visibility}
|
||||||
return (
|
setVisibility={setVisibility}
|
||||||
<button
|
visibilityScopeOrgId={visibilityScopeOrgId}
|
||||||
key={mode}
|
setVisibilityScopeOrgId={setVisibilityScopeOrgId}
|
||||||
type="button"
|
orgs={orgs}
|
||||||
onClick={() => setForm(prev => ({ ...prev, workspaceMode: mode }))}
|
showAdvanced={showAdvanced}
|
||||||
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-colors ${
|
setShowAdvanced={setShowAdvanced}
|
||||||
active ? 'bg-accent text-accent-fg' : 'text-slate-600 hover:bg-slate-50'
|
/>
|
||||||
}`}
|
{error && <div className="mt-4 text-[13px] text-red-600">{error}</div>}
|
||||||
>
|
<div className="mt-4 flex justify-end items-center gap-2 pt-1">
|
||||||
{label}
|
<Dialog.Close asChild>
|
||||||
</button>
|
<button className="px-4 py-2 border border-slate-200 rounded-xl text-[13px] text-slate-600 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring">
|
||||||
);
|
{t('cancel')}
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<p className="text-2xs text-slate-400 mt-1">
|
|
||||||
{t('workspace.modeHint')}
|
|
||||||
</p>
|
|
||||||
{(form.workspaceMode ?? 'persistent') === 'ephemeral' && (
|
|
||||||
<p className="text-2xs text-amber-600 mt-1" data-testid="ephemeral-warning">
|
|
||||||
{t('workspace.ephemeralWarning')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* スペース: 固定 or ピッカー(既定=個人) */}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<label className="block text-2xs text-slate-500 mb-1">{t('workspace.spaceLabel')}</label>
|
|
||||||
{initialSpaceId ? (
|
|
||||||
<div className="px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs text-slate-700 bg-slate-50 truncate">
|
|
||||||
{fixedSpace?.title ?? t('workspace.thisWorkspace')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<select
|
|
||||||
value={selectedSpaceId ?? ''}
|
|
||||||
onChange={e => setSelectedSpaceId(e.target.value || undefined)}
|
|
||||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
|
||||||
>
|
|
||||||
<option value="">{t('workspace.personalDefault')}</option>
|
|
||||||
{sortedSpaces
|
|
||||||
.filter(s => s.kind === 'case')
|
|
||||||
.map(s => (
|
|
||||||
<option key={s.id} value={s.id}>{s.title}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Attachments */}
|
|
||||||
<AttachmentDropzone attachments={attachments} onFilesChange={setAttachments} />
|
|
||||||
|
|
||||||
{/* Prompt coach (on-demand draft evaluation) */}
|
|
||||||
<PromptCoachPanel
|
|
||||||
body={form.body}
|
|
||||||
piece={initialPiece ?? form.piece}
|
|
||||||
onApplyRewrite={(text) => {
|
|
||||||
setForm(prev => ({ ...prev, body: text }));
|
|
||||||
saveDraft(text);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* MCP warnings (always visible when applicable) */}
|
|
||||||
{missingMcp.length > 0 && (
|
|
||||||
<div className="p-3 bg-yellow-50 dark:bg-yellow-500/15 border border-yellow-300 dark:border-yellow-500/30 rounded text-xs text-yellow-900 dark:text-yellow-300 space-y-2">
|
|
||||||
<div>
|
|
||||||
<strong>{t('mcp.required')}</strong> {missingMcp.join(', ')}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{missingMcp.map((id) => (
|
|
||||||
<a
|
|
||||||
key={id}
|
|
||||||
className="px-2 py-0.5 rounded bg-yellow-600 text-white hover:bg-yellow-700 text-2xs font-semibold"
|
|
||||||
href={`/auth/mcp/${encodeURIComponent(id)}/start`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{t('mcp.connect', { id })}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="text-2xs text-yellow-700 dark:text-yellow-300">
|
|
||||||
{t('mcp.note')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Advanced Settings toggle + content */}
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAdvanced(prev => !prev)}
|
|
||||||
className="px-3 py-1.5 border border-slate-200 rounded-xl text-xs font-bold text-slate-600 hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
{showAdvanced ? t('advanced.hide') : t('advanced.show')}
|
|
||||||
</button>
|
</button>
|
||||||
{showAdvanced && (
|
</Dialog.Close>
|
||||||
<div className="mt-3 space-y-4 border border-slate-100 rounded-xl p-4 bg-slate-50/50">
|
<button
|
||||||
{/* Row 1: Piece, Profile, Priority */}
|
disabled={submitting}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
data-testid="create-task-submit"
|
||||||
<div>
|
onClick={() => void handleSubmit()}
|
||||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.taskType')}</label>
|
className="px-4 py-2 bg-accent text-accent-fg rounded-xl text-[13px] font-bold disabled:opacity-50 hover:bg-accent-deep"
|
||||||
<select
|
>
|
||||||
value={form.piece}
|
{submitting ? t('submitting') : isScheduled ? t('submitSchedule') : t('submit')}
|
||||||
onChange={e => setForm(prev => ({ ...prev, piece: e.target.value }))}
|
</button>
|
||||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
|
||||||
>
|
|
||||||
<option value="auto">{t('advanced.auto')}</option>
|
|
||||||
{resolvedPieces.map(p => (
|
|
||||||
<option key={p.name} value={p.name}>{p.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.profile')}</label>
|
|
||||||
<select
|
|
||||||
value={form.profile}
|
|
||||||
onChange={e => setForm(prev => ({ ...prev, profile: e.target.value as CreateLocalTaskInput['profile'] }))}
|
|
||||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
|
||||||
>
|
|
||||||
{[['auto', 'auto'], ['fast', 'fast'], ['quality', 'quality']].map(([v, l]) => (
|
|
||||||
<option key={v} value={v}>{l}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.priority')}</label>
|
|
||||||
<select
|
|
||||||
value={form.priority}
|
|
||||||
onChange={e => setForm(prev => ({ ...prev, priority: e.target.value as CreateLocalTaskInput['priority'] }))}
|
|
||||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
|
||||||
>
|
|
||||||
{[['low', 'low'], ['medium', 'medium'], ['high', 'high']].map(([v, l]) => (
|
|
||||||
<option key={v} value={v}>{l}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 2: Output Format, Ask Policy */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.outputFormat')}</label>
|
|
||||||
<select
|
|
||||||
value={form.outputFormat}
|
|
||||||
onChange={e => setForm(prev => ({ ...prev, outputFormat: e.target.value as CreateLocalTaskInput['outputFormat'] }))}
|
|
||||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
|
||||||
>
|
|
||||||
{[['markdown', 'markdown'], ['text', 'text'], ['json', 'json']].map(([v, l]) => (
|
|
||||||
<option key={v} value={v}>{l}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.askPolicy')}</label>
|
|
||||||
<select
|
|
||||||
value={form.askPolicy}
|
|
||||||
onChange={e => setForm(prev => ({ ...prev, askPolicy: e.target.value as CreateLocalTaskInput['askPolicy'] }))}
|
|
||||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
|
||||||
>
|
|
||||||
<option value="low">{t('advanced.askLow')}</option>
|
|
||||||
<option value="high">{t('advanced.askHigh')}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 3: MCP disable, Skills disable checkboxes */}
|
|
||||||
<div className="flex flex-wrap gap-x-6 gap-y-2">
|
|
||||||
<label className="flex items-center gap-2 text-xs text-slate-600 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={mcpDisabled}
|
|
||||||
onChange={e => setMcpDisabled(e.target.checked)}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
{t('advanced.disableMcp')}
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 text-xs text-slate-600 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={skillsDisabled}
|
|
||||||
onChange={e => setSkillsDisabled(e.target.checked)}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
{t('advanced.disableSkills')}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Browser Session (only if active profiles exist) */}
|
|
||||||
{activeSessionProfiles.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.browserSession')}</label>
|
|
||||||
<select
|
|
||||||
value={browserSessionProfileId ?? ''}
|
|
||||||
onChange={e =>
|
|
||||||
setBrowserSessionProfileId(e.target.value ? Number(e.target.value) : null)
|
|
||||||
}
|
|
||||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
|
||||||
>
|
|
||||||
<option value="">{t('advanced.none')}</option>
|
|
||||||
{activeSessionProfiles.map(p => (
|
|
||||||
<option key={p.id} value={p.id}>{p.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<p className="text-2xs text-slate-400 mt-1">
|
|
||||||
{t('advanced.browserSessionHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Visibility */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-2xs text-slate-500 mb-1">{t('visibility.label')}</label>
|
|
||||||
<div className="flex gap-3 text-xs">
|
|
||||||
<label className="flex items-center gap-1 cursor-pointer">
|
|
||||||
<input type="radio" checked={visibility === 'private'} onChange={() => setVisibility('private')} />
|
|
||||||
{t('visibility.private')}
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-1 cursor-pointer">
|
|
||||||
<input type="radio" checked={visibility === 'org'} onChange={() => setVisibility('org')} disabled={orgs.length === 0} />
|
|
||||||
{t('visibility.org')}
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-1 cursor-pointer">
|
|
||||||
<input type="radio" checked={visibility === 'public'} onChange={() => setVisibility('public')} />
|
|
||||||
{t('visibility.public')}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{visibility === 'org' && orgs.length > 1 && (
|
|
||||||
<select
|
|
||||||
className="mt-1 px-2 py-1 border border-slate-200 rounded text-xs"
|
|
||||||
value={visibilityScopeOrgId ?? ''}
|
|
||||||
onChange={e => setVisibilityScopeOrgId(e.target.value)}
|
|
||||||
>
|
|
||||||
{orgs.map(o => <option key={o.orgId} value={o.orgId}>{o.orgName}</option>)}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
{visibility === 'org' && orgs.length === 1 && (
|
|
||||||
<div className="mt-1 text-2xs text-slate-500">{t('visibility.sharedWith', { org: orgs[0].orgName })}</div>
|
|
||||||
)}
|
|
||||||
{visibility === 'org' && orgs.length === 0 && (
|
|
||||||
<div className="mt-1 text-2xs text-slate-400">{t('visibility.orgLoginHint')}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Schedule toggle + sub-form */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="schedule-toggle"
|
|
||||||
checked={isScheduled}
|
|
||||||
onChange={e => setIsScheduled(e.target.checked)}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
<label htmlFor="schedule-toggle" className="text-xs text-slate-600 cursor-pointer">{t('schedule.enable')}</label>
|
|
||||||
</div>
|
|
||||||
{isScheduled && (
|
|
||||||
<ScheduleFields schedule={schedule} onChange={setSchedule} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="text-[13px] text-red-600">{error}</div>}
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div className="flex justify-end items-center gap-2 pt-1">
|
|
||||||
<Dialog.Close asChild>
|
|
||||||
<button className="px-4 py-2 border border-slate-200 rounded-xl text-[13px] text-slate-600 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring">
|
|
||||||
{t('cancel')}
|
|
||||||
</button>
|
|
||||||
</Dialog.Close>
|
|
||||||
<button
|
|
||||||
disabled={submitting}
|
|
||||||
data-testid="create-task-submit"
|
|
||||||
onClick={() => void handleSubmit()}
|
|
||||||
className="px-4 py-2 bg-accent text-accent-fg rounded-xl text-[13px] font-bold disabled:opacity-50 hover:bg-accent-deep"
|
|
||||||
>
|
|
||||||
{submitting ? t('submitting') : isScheduled ? t('submitSchedule') : t('submit')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|||||||
267
ui/src/components/create/CreateTaskDialogAdvancedSection.tsx
Normal file
267
ui/src/components/create/CreateTaskDialogAdvancedSection.tsx
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { CreateLocalTaskInput, Visibility } from '../../api';
|
||||||
|
import { ScheduleFields } from './ScheduleFields';
|
||||||
|
|
||||||
|
interface OrgOption {
|
||||||
|
orgId: string;
|
||||||
|
orgName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LlmWorkerOption {
|
||||||
|
id: string;
|
||||||
|
model: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
reasoningEfforts?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateTaskDialogAdvancedSectionProps {
|
||||||
|
form: CreateLocalTaskInput;
|
||||||
|
setForm: (updater: (previous: CreateLocalTaskInput) => CreateLocalTaskInput) => void;
|
||||||
|
resolvedPieces: Array<{ name: string }>;
|
||||||
|
activeSessionProfiles: Array<{ id: number; label: string }>;
|
||||||
|
enabledLlmWorkers: LlmWorkerOption[];
|
||||||
|
isScheduled: boolean;
|
||||||
|
setIsScheduled: (value: boolean) => void;
|
||||||
|
schedule: {
|
||||||
|
scheduleType: string;
|
||||||
|
hour: number;
|
||||||
|
minute: number;
|
||||||
|
dayOfWeek: number;
|
||||||
|
dayOfMonth: number;
|
||||||
|
cronExpression: string;
|
||||||
|
scheduledAt: string;
|
||||||
|
};
|
||||||
|
setSchedule: (updater: (previous: CreateTaskDialogAdvancedSectionProps['schedule']) => CreateTaskDialogAdvancedSectionProps['schedule']) => void;
|
||||||
|
browserSessionProfileId: number | null;
|
||||||
|
setBrowserSessionProfileId: (value: number | null) => void;
|
||||||
|
visibility: Visibility;
|
||||||
|
setVisibility: (value: Visibility) => void;
|
||||||
|
visibilityScopeOrgId: string | null;
|
||||||
|
setVisibilityScopeOrgId: (value: string) => void;
|
||||||
|
orgs: OrgOption[];
|
||||||
|
showAdvanced: boolean;
|
||||||
|
setShowAdvanced: (value: (previous: boolean) => boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateTaskDialogAdvancedSection({
|
||||||
|
form,
|
||||||
|
setForm,
|
||||||
|
resolvedPieces,
|
||||||
|
activeSessionProfiles,
|
||||||
|
enabledLlmWorkers,
|
||||||
|
isScheduled,
|
||||||
|
setIsScheduled,
|
||||||
|
schedule,
|
||||||
|
setSchedule,
|
||||||
|
browserSessionProfileId,
|
||||||
|
setBrowserSessionProfileId,
|
||||||
|
visibility,
|
||||||
|
setVisibility,
|
||||||
|
visibilityScopeOrgId,
|
||||||
|
setVisibilityScopeOrgId,
|
||||||
|
orgs,
|
||||||
|
showAdvanced,
|
||||||
|
setShowAdvanced,
|
||||||
|
}: CreateTaskDialogAdvancedSectionProps) {
|
||||||
|
const { t } = useTranslation('create');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAdvanced(prev => !prev)}
|
||||||
|
className="px-3 py-1.5 border border-slate-200 rounded-xl text-xs font-bold text-slate-600 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
{showAdvanced ? t('advanced.hide') : t('advanced.show')}
|
||||||
|
</button>
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="mt-3 space-y-4 border border-slate-100 rounded-xl p-4 bg-slate-50/50">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.taskType')}</label>
|
||||||
|
<select
|
||||||
|
value={form.piece}
|
||||||
|
onChange={e => setForm(prev => ({ ...prev, piece: e.target.value }))}
|
||||||
|
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="auto">{t('advanced.auto')}</option>
|
||||||
|
{resolvedPieces.map(p => (
|
||||||
|
<option key={p.name} value={p.name}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.profile')}</label>
|
||||||
|
<select
|
||||||
|
value={form.profile}
|
||||||
|
disabled={!!form.llmWorkerId}
|
||||||
|
onChange={e => setForm(prev => ({ ...prev, profile: e.target.value as CreateLocalTaskInput['profile'] }))}
|
||||||
|
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{['auto', 'fast', 'quality'].map(v => (
|
||||||
|
<option key={v} value={v}>{v}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{!!form.llmWorkerId && (
|
||||||
|
<p className="text-2xs text-slate-400 mt-1">{t('advanced.workerOverridesProfile')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.priority')}</label>
|
||||||
|
<select
|
||||||
|
value={form.priority}
|
||||||
|
onChange={e => setForm(prev => ({ ...prev, priority: e.target.value as CreateLocalTaskInput['priority'] }))}
|
||||||
|
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{['low', 'medium', 'high'].map(v => (
|
||||||
|
<option key={v} value={v}>{v}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.workerLabel')}</label>
|
||||||
|
<select
|
||||||
|
data-testid="create-task-llm-worker"
|
||||||
|
value={form.llmWorkerId ?? ''}
|
||||||
|
disabled={isScheduled}
|
||||||
|
onChange={e => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setForm(prev => ({ ...prev, llmWorkerId: value || null, llmEffort: null }));
|
||||||
|
}}
|
||||||
|
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<option value="">{t('advanced.workerAuto')}</option>
|
||||||
|
{enabledLlmWorkers.map(w => (
|
||||||
|
<option key={w.id} value={w.id}>{`${w.model} @ ${w.id}`}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.effortLabel')}</label>
|
||||||
|
<select
|
||||||
|
data-testid="create-task-llm-effort"
|
||||||
|
value={form.llmEffort ?? ''}
|
||||||
|
disabled={!form.llmWorkerId || isScheduled}
|
||||||
|
onChange={e => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setForm(prev => ({ ...prev, llmEffort: value || null }));
|
||||||
|
}}
|
||||||
|
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<option value="">{t('advanced.effortNone')}</option>
|
||||||
|
{(enabledLlmWorkers.find(w => w.id === form.llmWorkerId)?.reasoningEfforts ?? []).map(effort => (
|
||||||
|
<option key={effort} value={effort}>{effort}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{isScheduled && (
|
||||||
|
<p className="text-2xs text-slate-400 sm:col-span-2" data-testid="create-task-llm-scheduled-hint">
|
||||||
|
{t('advanced.llmNotForScheduled')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.outputFormat')}</label>
|
||||||
|
<select
|
||||||
|
value={form.outputFormat}
|
||||||
|
onChange={e => setForm(prev => ({ ...prev, outputFormat: e.target.value as CreateLocalTaskInput['outputFormat'] }))}
|
||||||
|
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{['markdown', 'text', 'json'].map(v => (
|
||||||
|
<option key={v} value={v}>{v}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.askPolicy')}</label>
|
||||||
|
<select
|
||||||
|
value={form.askPolicy}
|
||||||
|
onChange={e => setForm(prev => ({ ...prev, askPolicy: e.target.value as CreateLocalTaskInput['askPolicy'] }))}
|
||||||
|
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="low">{t('advanced.askLow')}</option>
|
||||||
|
<option value="high">{t('advanced.askHigh')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeSessionProfiles.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.browserSession')}</label>
|
||||||
|
<select
|
||||||
|
value={browserSessionProfileId ?? ''}
|
||||||
|
onChange={e => setBrowserSessionProfileId(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="">{t('advanced.none')}</option>
|
||||||
|
{activeSessionProfiles.map(p => (
|
||||||
|
<option key={p.id} value={p.id}>{p.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-2xs text-slate-400 mt-1">{t('advanced.browserSessionHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-2xs text-slate-500 mb-1">{t('visibility.label')}</label>
|
||||||
|
<div className="flex gap-3 text-xs">
|
||||||
|
<label className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="radio" checked={visibility === 'private'} onChange={() => setVisibility('private')} />
|
||||||
|
{t('visibility.private')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="radio" checked={visibility === 'org'} onChange={() => setVisibility('org')} disabled={orgs.length === 0} />
|
||||||
|
{t('visibility.org')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="radio" checked={visibility === 'public'} onChange={() => setVisibility('public')} />
|
||||||
|
{t('visibility.public')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{visibility === 'org' && orgs.length > 1 && (
|
||||||
|
<select
|
||||||
|
className="mt-1 px-2 py-1 border border-slate-200 rounded text-xs"
|
||||||
|
value={visibilityScopeOrgId ?? ''}
|
||||||
|
onChange={e => setVisibilityScopeOrgId(e.target.value)}
|
||||||
|
>
|
||||||
|
{orgs.map(o => <option key={o.orgId} value={o.orgId}>{o.orgName}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{visibility === 'org' && orgs.length === 1 && (
|
||||||
|
<div className="mt-1 text-2xs text-slate-500">{t('visibility.sharedWith', { org: orgs[0].orgName })}</div>
|
||||||
|
)}
|
||||||
|
{visibility === 'org' && orgs.length === 0 && (
|
||||||
|
<div className="mt-1 text-2xs text-slate-400">{t('visibility.orgLoginHint')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="schedule-toggle"
|
||||||
|
checked={isScheduled}
|
||||||
|
onChange={e => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
setIsScheduled(checked);
|
||||||
|
if (checked) {
|
||||||
|
setForm(prev => ({ ...prev, llmWorkerId: null, llmEffort: null }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="schedule-toggle" className="text-xs text-slate-600 cursor-pointer">{t('schedule.enable')}</label>
|
||||||
|
</div>
|
||||||
|
{isScheduled && (
|
||||||
|
<ScheduleFields schedule={schedule} onChange={setSchedule} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
ui/src/components/create/CreateTaskDialogCoreSection.tsx
Normal file
172
ui/src/components/create/CreateTaskDialogCoreSection.tsx
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { CreateLocalTaskInput } from '../../api';
|
||||||
|
import { AttachmentDropzone } from './AttachmentDropzone';
|
||||||
|
import { PromptCoachPanel } from './PromptCoachPanel';
|
||||||
|
|
||||||
|
interface SpaceOption {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
kind?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrgOption {
|
||||||
|
orgId: string;
|
||||||
|
orgName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateTaskDialogCoreSectionProps {
|
||||||
|
initialPiece?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
initialSpaceId?: string;
|
||||||
|
fixedSpace?: { title: string };
|
||||||
|
selectedSpaceId?: string;
|
||||||
|
setSelectedSpaceId: (value: string | undefined) => void;
|
||||||
|
form: CreateLocalTaskInput;
|
||||||
|
setForm: (updater: (previous: CreateLocalTaskInput) => CreateLocalTaskInput) => void;
|
||||||
|
attachments: Array<{ name: string; contentBase64: string }>;
|
||||||
|
setAttachments: (value: Array<{ name: string; contentBase64: string }>) => void;
|
||||||
|
resolvedPieces: Array<{ name: string }>;
|
||||||
|
missingMcp: string[];
|
||||||
|
onSubmit: () => Promise<void>;
|
||||||
|
saveDraft: (value: string) => void;
|
||||||
|
sortedSpaces: SpaceOption[];
|
||||||
|
orgs: OrgOption[];
|
||||||
|
visibility: 'private' | 'org' | 'public';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateTaskDialogCoreSection({
|
||||||
|
initialPiece,
|
||||||
|
placeholder,
|
||||||
|
initialSpaceId,
|
||||||
|
fixedSpace,
|
||||||
|
selectedSpaceId,
|
||||||
|
setSelectedSpaceId,
|
||||||
|
form,
|
||||||
|
setForm,
|
||||||
|
attachments,
|
||||||
|
setAttachments,
|
||||||
|
resolvedPieces,
|
||||||
|
missingMcp,
|
||||||
|
onSubmit,
|
||||||
|
saveDraft,
|
||||||
|
sortedSpaces,
|
||||||
|
orgs,
|
||||||
|
visibility,
|
||||||
|
}: CreateTaskDialogCoreSectionProps) {
|
||||||
|
const { t } = useTranslation('create');
|
||||||
|
const selectedPiece = resolvedPieces.find(p => p.name === form.piece);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[13px] text-slate-600 mb-1.5">{t('body.label')}</label>
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
data-testid="create-task-body"
|
||||||
|
value={form.body}
|
||||||
|
onChange={e => {
|
||||||
|
const body = e.target.value;
|
||||||
|
setForm(prev => ({ ...prev, body }));
|
||||||
|
saveDraft(body);
|
||||||
|
}}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
void onSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
rows={8}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:border-accent resize-y leading-relaxed"
|
||||||
|
placeholder={placeholder ?? (initialPiece === 'help' ? t('body.placeholderHelp') : t('body.placeholder'))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-2xs text-slate-500 mb-1">{t('workspace.modeLabel')}</label>
|
||||||
|
<div className="inline-flex rounded-lg border border-slate-200 p-0.5">
|
||||||
|
{([['persistent', t('workspace.persistent')], ['ephemeral', t('workspace.ephemeral')]] as const).map(([mode, label]) => {
|
||||||
|
const active = (form.workspaceMode ?? 'persistent') === mode;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm(prev => ({ ...prev, workspaceMode: mode }))}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-colors ${
|
||||||
|
active ? 'bg-accent text-accent-fg' : 'text-slate-600 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-2xs text-slate-400 mt-1">{t('workspace.modeHint')}</p>
|
||||||
|
{(form.workspaceMode ?? 'persistent') === 'ephemeral' && (
|
||||||
|
<p className="text-2xs text-amber-600 mt-1" data-testid="ephemeral-warning">
|
||||||
|
{t('workspace.ephemeralWarning')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<label className="block text-2xs text-slate-500 mb-1">{t('workspace.spaceLabel')}</label>
|
||||||
|
{initialSpaceId ? (
|
||||||
|
<div className="px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs text-slate-700 bg-slate-50 truncate">
|
||||||
|
{fixedSpace?.title ?? t('workspace.thisWorkspace')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={selectedSpaceId ?? ''}
|
||||||
|
onChange={e => setSelectedSpaceId(e.target.value || undefined)}
|
||||||
|
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="">{t('workspace.personalDefault')}</option>
|
||||||
|
{sortedSpaces
|
||||||
|
.filter(s => s.kind === 'case')
|
||||||
|
.map(s => (
|
||||||
|
<option key={s.id} value={s.id}>{s.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AttachmentDropzone attachments={attachments} onFilesChange={setAttachments} />
|
||||||
|
|
||||||
|
<PromptCoachPanel
|
||||||
|
body={form.body}
|
||||||
|
piece={initialPiece ?? form.piece}
|
||||||
|
onApplyRewrite={(text) => {
|
||||||
|
setForm(prev => ({ ...prev, body: text }));
|
||||||
|
saveDraft(text);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{missingMcp.length > 0 && (
|
||||||
|
<div className="p-3 bg-yellow-50 dark:bg-yellow-500/15 border border-yellow-300 dark:border-yellow-500/30 rounded text-xs text-yellow-900 dark:text-yellow-300 space-y-2">
|
||||||
|
<div>
|
||||||
|
<strong>{t('mcp.required')}</strong> {missingMcp.join(', ')}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{missingMcp.map((id) => (
|
||||||
|
<a
|
||||||
|
key={id}
|
||||||
|
className="px-2 py-0.5 rounded bg-yellow-600 text-white hover:bg-yellow-700 text-2xs font-semibold"
|
||||||
|
href={`/auth/mcp/${encodeURIComponent(id)}/start`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t('mcp.connect', { id })}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xs text-yellow-700 dark:text-yellow-300">
|
||||||
|
{t('mcp.note')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -10,6 +10,8 @@ interface PromptCoachPanelProps {
|
|||||||
piece?: string;
|
piece?: string;
|
||||||
/** Inject the rewrite suggestion back into the textarea. */
|
/** Inject the rewrite suggestion back into the textarea. */
|
||||||
onApplyRewrite: (text: string) => void;
|
onApplyRewrite: (text: string) => void;
|
||||||
|
/** Compact spacing for the chat composer popover. */
|
||||||
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreColor(score: number, max: number): string {
|
function scoreColor(score: number, max: number): string {
|
||||||
@ -19,7 +21,7 @@ function scoreColor(score: number, max: number): string {
|
|||||||
return 'text-rose-600 dark:text-rose-400';
|
return 'text-rose-600 dark:text-rose-400';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PromptCoachPanel({ body, piece, onApplyRewrite }: PromptCoachPanelProps) {
|
export function PromptCoachPanel({ body, piece, onApplyRewrite, compact = false }: PromptCoachPanelProps) {
|
||||||
const { t } = useTranslation('create');
|
const { t } = useTranslation('create');
|
||||||
const mutation = useMutation<PromptCoachResult, Error, void>({
|
const mutation = useMutation<PromptCoachResult, Error, void>({
|
||||||
mutationFn: () => evaluatePrompt({ instruction: body.trim(), piece }),
|
mutationFn: () => evaluatePrompt({ instruction: body.trim(), piece }),
|
||||||
@ -48,7 +50,7 @@ export function PromptCoachPanel({ body, piece, onApplyRewrite }: PromptCoachPan
|
|||||||
)}
|
)}
|
||||||
{mutation.isPending ? t('coach.evaluating') : t('coach.evaluate')}
|
{mutation.isPending ? t('coach.evaluating') : t('coach.evaluate')}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-2xs text-slate-400">{t('coach.hint')}</span>
|
{!compact && <span className="text-2xs text-slate-400">{t('coach.hint')}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mutation.isError && (
|
{mutation.isError && (
|
||||||
@ -56,7 +58,7 @@ export function PromptCoachPanel({ body, piece, onApplyRewrite }: PromptCoachPan
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div className="mt-3 space-y-3 border border-slate-200 dark:border-slate-700 rounded-xl p-4 bg-slate-50/60 dark:bg-slate-800/40">
|
<div className={`mt-3 space-y-3 border border-slate-200 dark:border-slate-700 rounded-xl bg-slate-50/60 dark:bg-slate-800/40 ${compact ? 'p-3 max-h-56 overflow-y-auto' : 'p-4'}`}>
|
||||||
{/* Overall score */}
|
{/* Overall score */}
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-xs font-bold text-slate-500">{t('coach.overall')}</span>
|
<span className="text-xs font-bold text-slate-500">{t('coach.overall')}</span>
|
||||||
|
|||||||
36
ui/src/components/notifications/ToastHost.test.tsx
Normal file
36
ui/src/components/notifications/ToastHost.test.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import '../../test/dom-setup';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { ToastHost } from './ToastHost';
|
||||||
|
|
||||||
|
describe('ToastHost', () => {
|
||||||
|
it('stacks notifications and dismisses one by its close button', async () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
render(<ToastHost onDismiss={onDismiss} toasts={[
|
||||||
|
{ id: 'first', message: '最初の通知', variant: 'info' },
|
||||||
|
{ id: 'second', title: '完了', message: '次の通知', variant: 'success' },
|
||||||
|
]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('最初の通知')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('次の通知')).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: '完了を閉じる' }));
|
||||||
|
expect(onDismiss).toHaveBeenCalledWith('second');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes an action and dismisses the toast', async () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
const onAction = vi.fn();
|
||||||
|
render(<ToastHost onDismiss={onDismiss} toasts={[{ id: 'task-1', title: 'タスク完了', message: '確認できます', variant: 'success', actionLabel: 'タスクを開く', onAction }]} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'タスクを開く' }));
|
||||||
|
expect(onAction).toHaveBeenCalledOnce();
|
||||||
|
expect(onDismiss).toHaveBeenCalledWith('task-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an optional completion visual beside the notification', () => {
|
||||||
|
render(<ToastHost onDismiss={vi.fn()} toasts={[{ id: 'task-1', message: '完了', variant: 'success', visual: <span data-testid="pet-visual">pet</span> }]} />);
|
||||||
|
expect(screen.getByTestId('pet-visual')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
44
ui/src/components/notifications/ToastHost.tsx
Normal file
44
ui/src/components/notifications/ToastHost.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import type { ToastState } from '../../hooks/useToast';
|
||||||
|
|
||||||
|
interface ToastHostProps {
|
||||||
|
toasts: ToastState[];
|
||||||
|
onDismiss: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClass = {
|
||||||
|
success: 'border-emerald-200 bg-emerald-50 text-emerald-950',
|
||||||
|
error: 'border-red-200 bg-red-50 text-red-950',
|
||||||
|
info: 'border-sky-200 bg-sky-50 text-sky-950',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 右下に積み上がる、OS 通知権限に依存しないアプリ内通知。 */
|
||||||
|
export function ToastHost({ toasts, onDismiss }: ToastHostProps) {
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section aria-label="アプリ内通知" className="pointer-events-none fixed inset-x-3 bottom-[max(0.75rem,env(safe-area-inset-bottom))] z-[70] flex max-w-sm flex-col-reverse gap-2 sm:left-auto sm:right-4">
|
||||||
|
{toasts.map(toast => (
|
||||||
|
<article
|
||||||
|
key={toast.id}
|
||||||
|
role={toast.variant === 'error' ? 'alert' : 'status'}
|
||||||
|
className={`pointer-events-auto rounded-xl border px-3 py-2.5 shadow-lg motion-safe:animate-[toast-enter_160ms_ease-out] ${variantClass[toast.variant]}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{toast.visual && <div className="shrink-0" aria-hidden="true">{toast.visual}</div>}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{toast.title && <p className="text-xs font-semibold">{toast.title}</p>}
|
||||||
|
<p className="text-sm">{toast.message}</p>
|
||||||
|
{toast.actionLabel && toast.onAction && (
|
||||||
|
<button type="button" onClick={() => { toast.onAction?.(); onDismiss(toast.id); }} className="mt-1 text-xs font-semibold underline underline-offset-2">
|
||||||
|
{toast.actionLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => onDismiss(toast.id)} aria-label={`${toast.title ?? toast.message}を閉じる`} className="rounded p-1 text-current/70 hover:bg-black/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||||
|
<span aria-hidden>×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
ui/src/components/notifications/ToastPet.tsx
Normal file
21
ui/src/components/notifications/ToastPet.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useActivePet } from '../../hooks/useActivePet';
|
||||||
|
import { usePetFrameAnalysis } from '../../hooks/usePetFrameAnalysis';
|
||||||
|
import { PetSprite } from '../pets/PetSprite';
|
||||||
|
|
||||||
|
export function ToastPet() {
|
||||||
|
const { data } = useActivePet();
|
||||||
|
const framesPerRow = usePetFrameAnalysis(data?.spriteUrl ?? null, data?.gridCols ?? null, data?.gridRows ?? null);
|
||||||
|
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
const update = () => setPrefersReducedMotion(media.matches);
|
||||||
|
update();
|
||||||
|
media.addEventListener('change', update);
|
||||||
|
return () => media.removeEventListener('change', update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!data?.settings.enabled || !data.pet) return null;
|
||||||
|
return <PetSprite name={data.pet.name} imageUrl={data.imageUrl} frameWidth={data.frameWidth} frameHeight={data.frameHeight} gridCols={data.gridCols} gridRows={data.gridRows} framesPerRow={framesPerRow} state="done" size={48} reducedMotion={data.settings.reducedMotion || prefersReducedMotion} />;
|
||||||
|
}
|
||||||
@ -1,12 +1,17 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useActivePet } from '../../hooks/useActivePet';
|
import { useActivePet } from '../../hooks/useActivePet';
|
||||||
import { useNodeAnimationState } from '../../hooks/useNodeAnimationState';
|
import { useNodeAnimationState } from '../../hooks/useNodeAnimationState';
|
||||||
|
import { useStableNodePromotion } from '../../hooks/useStableNodePromotion';
|
||||||
import { usePetFrameAnalysis } from '../../hooks/usePetFrameAnalysis';
|
import { usePetFrameAnalysis } from '../../hooks/usePetFrameAnalysis';
|
||||||
import { extractLatestToolName, petStateFromJobStatus, type PetRuntimeState } from '../../lib/pets/petState';
|
import type { LastToolEvent } from '../../hooks/useJobStream';
|
||||||
|
import { petStateFromJobStatus, type PetRuntimeState } from '../../lib/pets/petState';
|
||||||
import { PetSprite } from './PetSprite';
|
import { PetSprite } from './PetSprite';
|
||||||
import { ToolSpark } from './ToolSpark';
|
import { ToolSpark } from './ToolSpark';
|
||||||
|
|
||||||
const JUMP_DURATION_MS = 1500;
|
// A whole number of petJump cycles (3 × 0.55s) so the hop ends near
|
||||||
|
// translateY(0) instead of snapping down from mid-arc when it stops
|
||||||
|
// (Fable review #2).
|
||||||
|
const JUMP_DURATION_MS = 1650;
|
||||||
const DONE_FLOURISH_MS = 1000;
|
const DONE_FLOURISH_MS = 1000;
|
||||||
|
|
||||||
function usePrefersReducedMotion(): boolean {
|
function usePrefersReducedMotion(): boolean {
|
||||||
@ -26,14 +31,17 @@ function usePrefersReducedMotion(): boolean {
|
|||||||
export function ChatPetOverlay({
|
export function ChatPetOverlay({
|
||||||
taskId,
|
taskId,
|
||||||
taskStatus,
|
taskStatus,
|
||||||
currentActivity,
|
lastToolEvent,
|
||||||
workerId,
|
workerId,
|
||||||
lastBackendId,
|
lastBackendId,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
taskId: number | null;
|
taskId: number | null;
|
||||||
taskStatus: string | null;
|
taskStatus: string | null;
|
||||||
currentActivity: string | null;
|
/** Most recent SSE tool_use/tool_result, keyed by callId (Pets Phase 1,
|
||||||
|
* U0). Drives the jump + spark triggers — see
|
||||||
|
* docs/superpowers/specs/2026-07-10-pets-flicker-and-tool-effects-design.md */
|
||||||
|
lastToolEvent: LastToolEvent | null;
|
||||||
workerId: string | null;
|
workerId: string | null;
|
||||||
/**
|
/**
|
||||||
* Physical backend id when the worker is a proxy (LiteLLM deployment
|
* Physical backend id when the worker is a proxy (LiteLLM deployment
|
||||||
@ -60,56 +68,81 @@ export function ChatPetOverlay({
|
|||||||
// anyway. Prefer the proxy-backend mapping over the worker mapping
|
// anyway. Prefer the proxy-backend mapping over the worker mapping
|
||||||
// — same precedence as useActivePet uses for sprite selection.
|
// — same precedence as useActivePet uses for sprite selection.
|
||||||
const nodeAnimState = useNodeAnimationState(lastBackendId ?? workerId ?? null);
|
const nodeAnimState = useNodeAnimationState(lastBackendId ?? workerId ?? null);
|
||||||
|
// U3: hold the promotion for ~2 idle polls before letting it lapse, so a
|
||||||
|
// single missed busy sample on the shared node doesn't flap the pet back
|
||||||
|
// to idle and immediately back up.
|
||||||
|
const stableNodeAnimState = useStableNodePromotion(nodeAnimState);
|
||||||
|
|
||||||
const taskBaseState = petStateFromJobStatus(taskStatus, taskId);
|
const taskBaseState = petStateFromJobStatus(taskStatus, taskId);
|
||||||
// Promote an 'idle' base state to 'running' when the backing node is
|
// Promote an 'idle' base state to 'running' when the backing node is
|
||||||
// actively processing. Don't override informative states like
|
// actively processing. Don't override informative states like
|
||||||
// 'dispatching', 'waiting', 'done', or 'error' — those carry signal
|
// 'dispatching', 'waiting', 'done', or 'error' — those carry signal
|
||||||
// that node.busy doesn't.
|
// that node.busy doesn't. Real task-status states stay immediate; only
|
||||||
const baseState: PetRuntimeState = taskBaseState === 'idle' && nodeAnimState === 'running'
|
// this node-derived promotion goes through the hysteresis above.
|
||||||
|
const baseState: PetRuntimeState = taskBaseState === 'idle' && stableNodeAnimState === 'running'
|
||||||
? 'running'
|
? 'running'
|
||||||
: taskBaseState;
|
: taskBaseState;
|
||||||
const baseStateRef = useRef(baseState);
|
const baseStateRef = useRef(baseState);
|
||||||
baseStateRef.current = baseState;
|
baseStateRef.current = baseState;
|
||||||
|
|
||||||
|
const reducedMotion = (data?.settings.reducedMotion ?? false) || prefersReducedMotion;
|
||||||
|
|
||||||
const [displayState, setDisplayState] = useState<PetRuntimeState>('idle');
|
const [displayState, setDisplayState] = useState<PetRuntimeState>('idle');
|
||||||
|
|
||||||
|
// U1/U0: jump is a one-shot overlay on the outer sprite wrapper, kept
|
||||||
|
// entirely separate from `displayState` (the pose fed to PetSprite's
|
||||||
|
// frame-cycle) so re-triggering it never restarts the inner animation.
|
||||||
|
// Triggered by SSE `tool_use` (callId change) instead of the 5s-polled
|
||||||
|
// `currentActivity`, which also carried `LLM: …` status text and caused
|
||||||
|
// false jumps/sparks.
|
||||||
|
const [jumping, setJumping] = useState(false);
|
||||||
|
const jumpTimerRef = useRef<number | null>(null);
|
||||||
|
const lastJumpCallIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (jumpTimerRef.current != null) window.clearTimeout(jumpTimerRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Reset display to base state whenever it changes; brief 'done' flourish then
|
// Reset display to base state whenever it changes; brief 'done' flourish then
|
||||||
// settle to idle so the wave doesn't loop forever.
|
// settle to idle so the wave doesn't loop forever.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDisplayState(baseState);
|
setDisplayState(baseState);
|
||||||
|
// Base state changed out from under an in-flight jump (e.g. the task
|
||||||
|
// just finished) — stop the hop rather than let it bounce over a
|
||||||
|
// done/error/waiting pose it no longer applies to.
|
||||||
|
if (baseState !== 'running' && baseState !== 'runningAlt' && baseState !== 'dispatching') {
|
||||||
|
if (jumpTimerRef.current != null) {
|
||||||
|
window.clearTimeout(jumpTimerRef.current);
|
||||||
|
jumpTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setJumping(false);
|
||||||
|
}
|
||||||
if (baseState !== 'done') return;
|
if (baseState !== 'done') return;
|
||||||
const timer = window.setTimeout(() => setDisplayState('idle'), DONE_FLOURISH_MS);
|
const timer = window.setTimeout(() => setDisplayState('idle'), DONE_FLOURISH_MS);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [baseState]);
|
}, [baseState]);
|
||||||
|
|
||||||
// When the current activity changes (= a new tool fired) during active
|
|
||||||
// execution, jump for ~1.5s and revert to whatever the base state is by then.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentActivity) return;
|
if (reducedMotion) return;
|
||||||
|
if (!lastToolEvent || !lastToolEvent.callId) return;
|
||||||
|
if (lastToolEvent.callId === lastJumpCallIdRef.current) return;
|
||||||
|
lastJumpCallIdRef.current = lastToolEvent.callId;
|
||||||
const active = baseStateRef.current;
|
const active = baseStateRef.current;
|
||||||
if (active !== 'running' && active !== 'runningAlt' && active !== 'dispatching') return;
|
if (active !== 'running' && active !== 'runningAlt' && active !== 'dispatching') return;
|
||||||
setDisplayState('jumping');
|
setJumping(true);
|
||||||
const timer = window.setTimeout(() => {
|
// U5: a later tool_use before this fires just pushes the end time out
|
||||||
const current = baseStateRef.current;
|
// (new callId -> this effect re-runs, clears + reschedules) — it never
|
||||||
if (current === 'running' || current === 'runningAlt' || current === 'dispatching') {
|
// toggles `jumping` off and back on, so the CSS animation on the outer
|
||||||
setDisplayState(current);
|
// wrapper never restarts.
|
||||||
}
|
if (jumpTimerRef.current != null) window.clearTimeout(jumpTimerRef.current);
|
||||||
// For other base states (done / error / idle / waiting) the dedicated
|
jumpTimerRef.current = window.setTimeout(() => {
|
||||||
// effects above will have taken over; don't fight them here.
|
setJumping(false);
|
||||||
|
jumpTimerRef.current = null;
|
||||||
}, JUMP_DURATION_MS);
|
}, JUMP_DURATION_MS);
|
||||||
return () => window.clearTimeout(timer);
|
}, [lastToolEvent, reducedMotion]);
|
||||||
}, [currentActivity]);
|
|
||||||
|
|
||||||
const toolName = useMemo(
|
|
||||||
() => extractLatestToolName(currentActivity),
|
|
||||||
[currentActivity],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading || !data?.settings.enabled || !data.pet) return null;
|
if (isLoading || !data?.settings.enabled || !data.pet) return null;
|
||||||
|
|
||||||
const reducedMotion = data.settings.reducedMotion || prefersReducedMotion;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={className ? `chat-pet-overlay ${className}` : 'chat-pet-overlay'}
|
className={className ? `chat-pet-overlay ${className}` : 'chat-pet-overlay'}
|
||||||
@ -117,8 +150,7 @@ export function ChatPetOverlay({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<ToolSpark
|
<ToolSpark
|
||||||
toolName={toolName}
|
event={lastToolEvent}
|
||||||
activityKey={currentActivity}
|
|
||||||
enabled={data.settings.toolSparkEnabled}
|
enabled={data.settings.toolSparkEnabled}
|
||||||
reducedMotion={reducedMotion}
|
reducedMotion={reducedMotion}
|
||||||
/>
|
/>
|
||||||
@ -131,6 +163,7 @@ export function ChatPetOverlay({
|
|||||||
gridRows={data.gridRows}
|
gridRows={data.gridRows}
|
||||||
framesPerRow={framesPerRow}
|
framesPerRow={framesPerRow}
|
||||||
state={displayState}
|
state={displayState}
|
||||||
|
jumping={jumping}
|
||||||
size={data.settings.size}
|
size={data.settings.size}
|
||||||
reducedMotion={reducedMotion}
|
reducedMotion={reducedMotion}
|
||||||
/>
|
/>
|
||||||
|
|||||||
129
ui/src/components/pets/PetSprite.test.tsx
Normal file
129
ui/src/components/pets/PetSprite.test.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import '../../test/dom-setup';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { PetSprite } from './PetSprite';
|
||||||
|
|
||||||
|
describe('PetSprite', () => {
|
||||||
|
it('falls back when the configured pet image cannot be loaded', async () => {
|
||||||
|
const OriginalImage = globalThis.Image;
|
||||||
|
class FailedImage {
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
set src(_: string) { queueMicrotask(() => this.onerror?.()); }
|
||||||
|
}
|
||||||
|
vi.stubGlobal('Image', FailedImage);
|
||||||
|
try {
|
||||||
|
const { container } = render(<PetSprite name="Toast pet" imageUrl="/broken.png" frameWidth={null} frameHeight={null} gridCols={null} gridRows={null} framesPerRow={null} state="done" size={48} reducedMotion />);
|
||||||
|
await waitFor(() => expect(container.querySelector('.pet-sprite-fallback')).toBeInTheDocument());
|
||||||
|
// U1: the outer (titled) wrapper only carries the jump overlay now —
|
||||||
|
// the base-state class lives on the nested `.pet-sprite-pose` layer.
|
||||||
|
expect(screen.getByTitle('Toast pet')).toHaveClass('pet-sprite');
|
||||||
|
expect(container.querySelector('.pet-sprite-pose')).toHaveClass('pet-sprite-done');
|
||||||
|
} finally {
|
||||||
|
vi.stubGlobal('Image', OriginalImage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('U1: jumping never changes the inner frame-cycle animation name', () => {
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<PetSprite
|
||||||
|
name="Toast pet"
|
||||||
|
imageUrl="/sprite.png"
|
||||||
|
frameWidth={null}
|
||||||
|
frameHeight={null}
|
||||||
|
gridCols={8}
|
||||||
|
gridRows={9}
|
||||||
|
framesPerRow={[8, 8, 8, 8, 8, 8, 8, 8, 8]}
|
||||||
|
state="running"
|
||||||
|
jumping={false}
|
||||||
|
size={48}
|
||||||
|
reducedMotion={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const grid = container.querySelector('.pet-sprite-grid') as HTMLElement;
|
||||||
|
expect(grid).toBeInTheDocument();
|
||||||
|
const before = grid.style.animation;
|
||||||
|
expect(before).toContain('petFrameCycle8');
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<PetSprite
|
||||||
|
name="Toast pet"
|
||||||
|
imageUrl="/sprite.png"
|
||||||
|
frameWidth={null}
|
||||||
|
frameHeight={null}
|
||||||
|
gridCols={8}
|
||||||
|
gridRows={9}
|
||||||
|
framesPerRow={[8, 8, 8, 8, 8, 8, 8, 8, 8]}
|
||||||
|
state="running"
|
||||||
|
jumping={true}
|
||||||
|
size={48}
|
||||||
|
reducedMotion={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const gridAfter = container.querySelector('.pet-sprite-grid') as HTMLElement;
|
||||||
|
expect(gridAfter.style.animation).toBe(before);
|
||||||
|
|
||||||
|
// The jump overlay lands on the outer (titled) wrapper, not the pose/grid layers.
|
||||||
|
expect(screen.getByTitle('Toast pet')).toHaveClass('pet-sprite-jumping');
|
||||||
|
expect(container.querySelector('.pet-sprite-pose')).not.toHaveClass('pet-sprite-jumping');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('U4: does not start the frame-cycle animation while framesPerRow is unresolved', () => {
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<PetSprite
|
||||||
|
name="Toast pet"
|
||||||
|
imageUrl="/sprite.png"
|
||||||
|
frameWidth={null}
|
||||||
|
frameHeight={null}
|
||||||
|
gridCols={8}
|
||||||
|
gridRows={9}
|
||||||
|
framesPerRow={null}
|
||||||
|
state="running"
|
||||||
|
size={48}
|
||||||
|
reducedMotion={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const grid = container.querySelector('.pet-sprite-grid') as HTMLElement;
|
||||||
|
expect(grid).toBeInTheDocument();
|
||||||
|
expect(grid.style.animation).toBe('');
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<PetSprite
|
||||||
|
name="Toast pet"
|
||||||
|
imageUrl="/sprite.png"
|
||||||
|
frameWidth={null}
|
||||||
|
frameHeight={null}
|
||||||
|
gridCols={8}
|
||||||
|
gridRows={9}
|
||||||
|
framesPerRow={[8, 8, 8, 8, 8, 8, 8, 8, 8]}
|
||||||
|
state="running"
|
||||||
|
size={48}
|
||||||
|
reducedMotion={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const gridAfter = container.querySelector('.pet-sprite-grid') as HTMLElement;
|
||||||
|
expect(gridAfter.style.animation).toContain('petFrameCycle8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('positions sprite frames from the full grid without wrapping', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<PetSprite
|
||||||
|
name="Toast pet"
|
||||||
|
imageUrl="/sprite.png"
|
||||||
|
frameWidth={null}
|
||||||
|
frameHeight={null}
|
||||||
|
gridCols={8}
|
||||||
|
gridRows={9}
|
||||||
|
framesPerRow={[8, 8, 8, 8, 8, 8, 8, 8, 8]}
|
||||||
|
state="running"
|
||||||
|
size={48}
|
||||||
|
reducedMotion={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const grid = container.querySelector('.pet-sprite-grid') as HTMLElement;
|
||||||
|
expect(grid.style.backgroundRepeat).toBe('no-repeat');
|
||||||
|
expect(Number.parseFloat(grid.style.getPropertyValue('--pet-frame-1-position'))).toBeCloseTo(100 / 7);
|
||||||
|
expect(grid.style.getPropertyValue('--pet-frame-7-position')).toBe('100%');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { rowIndexForState, type PetRuntimeState } from '../../lib/pets/petState';
|
import { rowIndexForState, type PetRuntimeState } from '../../lib/pets/petState';
|
||||||
|
|
||||||
const STATE_FRAME_DURATION: Record<PetRuntimeState, string> = {
|
const STATE_FRAME_DURATION: Record<PetRuntimeState, string> = {
|
||||||
@ -20,6 +21,7 @@ export function PetSprite({
|
|||||||
gridRows,
|
gridRows,
|
||||||
framesPerRow,
|
framesPerRow,
|
||||||
state,
|
state,
|
||||||
|
jumping = false,
|
||||||
size,
|
size,
|
||||||
reducedMotion,
|
reducedMotion,
|
||||||
}: {
|
}: {
|
||||||
@ -31,79 +33,129 @@ export function PetSprite({
|
|||||||
gridRows: number | null;
|
gridRows: number | null;
|
||||||
framesPerRow: number[] | null;
|
framesPerRow: number[] | null;
|
||||||
state: PetRuntimeState;
|
state: PetRuntimeState;
|
||||||
|
/** One-shot hop, layered on the outer wrapper only (U1). Never changes
|
||||||
|
* `state`, so the inner frame-cycle animation below never restarts
|
||||||
|
* when a tool fires mid-run. See
|
||||||
|
* docs/superpowers/specs/2026-07-10-pets-flicker-and-tool-effects-design.md */
|
||||||
|
jumping?: boolean;
|
||||||
size: number;
|
size: number;
|
||||||
reducedMotion: boolean;
|
reducedMotion: boolean;
|
||||||
}) {
|
}) {
|
||||||
const className = [
|
const [imageFailed, setImageFailed] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
setImageFailed(false);
|
||||||
|
if (!imageUrl) return;
|
||||||
|
const image = new Image();
|
||||||
|
image.onerror = () => setImageFailed(true);
|
||||||
|
image.src = imageUrl;
|
||||||
|
}, [imageUrl]);
|
||||||
|
const usableImageUrl = imageFailed ? null : imageUrl;
|
||||||
|
|
||||||
|
// Outer layer: the jump hop only. Deliberately carries no pose/state
|
||||||
|
// class so toggling it never disturbs the pose layer's continuous
|
||||||
|
// wobble animation or the inner frame-cycle (U1). Nested transforms
|
||||||
|
// compose, so a hop + the base-state wobble render together.
|
||||||
|
const outerClassName = [
|
||||||
'pet-sprite',
|
'pet-sprite',
|
||||||
|
jumping && !reducedMotion ? 'pet-sprite-jumping' : '',
|
||||||
|
reducedMotion ? 'pet-sprite-reduced' : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
// Pose layer: the continuous base-state wobble (idle/run/wait/done/
|
||||||
|
// error) and the sprite-sheet row/clip selection. Always reflects
|
||||||
|
// `state` as-is — jumping never reaches this layer.
|
||||||
|
const poseClassName = [
|
||||||
|
'pet-sprite-pose',
|
||||||
`pet-sprite-${state}`,
|
`pet-sprite-${state}`,
|
||||||
reducedMotion ? 'pet-sprite-reduced' : '',
|
reducedMotion ? 'pet-sprite-reduced' : '',
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
const useGridCrop = !!(imageUrl && gridCols && gridRows && gridCols > 0 && gridRows > 0);
|
const useGridCrop = !!(usableImageUrl && gridCols && gridRows && gridCols > 0 && gridRows > 0);
|
||||||
const useFrameCrop = !useGridCrop && !!(imageUrl && frameWidth && frameHeight);
|
const useFrameCrop = !useGridCrop && !!(usableImageUrl && frameWidth && frameHeight);
|
||||||
|
|
||||||
const stateRow = useGridCrop ? rowIndexForState(state, gridRows!) : 0;
|
const stateRow = useGridCrop ? rowIndexForState(state, gridRows!) : 0;
|
||||||
const bgPosY = useGridCrop && gridRows! > 1
|
const bgPosY = useGridCrop && gridRows! > 1
|
||||||
? `${(stateRow / (gridRows! - 1)) * 100}%`
|
? `${(stateRow / (gridRows! - 1)) * 100}%`
|
||||||
: '0%';
|
: '0%';
|
||||||
|
|
||||||
|
// U4: framesPerRow starts null and resolves asynchronously (canvas
|
||||||
|
// analysis of the spritesheet). Before it resolves, don't start the
|
||||||
|
// frame-cycle at all — cycling through the gridCols fallback walks
|
||||||
|
// past columns that may be transparent on rows with fewer filled
|
||||||
|
// frames, producing a flash right after mount. Stay on frame 0 (the
|
||||||
|
// default background-position) until analysis resolves.
|
||||||
|
const analysisResolved = framesPerRow !== null;
|
||||||
const detectedFrames = framesPerRow?.[stateRow];
|
const detectedFrames = framesPerRow?.[stateRow];
|
||||||
const rowFrameCount = Math.max(1, Math.min(8, detectedFrames ?? gridCols ?? 1));
|
const rowFrameCount = Math.max(1, Math.min(8, detectedFrames ?? gridCols ?? 1));
|
||||||
const cycleAnimation = useGridCrop && !reducedMotion && rowFrameCount > 1
|
const cycleAnimation = useGridCrop && !reducedMotion && analysisResolved && rowFrameCount > 1
|
||||||
? `petFrameCycle${rowFrameCount} ${STATE_FRAME_DURATION[state]} linear infinite`
|
? `petFrameCycle${rowFrameCount} ${STATE_FRAME_DURATION[state]} linear infinite`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const framePositions = useGridCrop
|
||||||
|
? Array.from({ length: 8 }, (_, index) => {
|
||||||
|
const clampedIndex = Math.min(index, gridCols! - 1);
|
||||||
|
return gridCols! > 1 ? `${(clampedIndex / (gridCols! - 1)) * 100}%` : '0%';
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={className}
|
className={outerClassName}
|
||||||
style={{
|
style={{ width: size, height: size }}
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
overflow: useGridCrop || useFrameCrop ? 'hidden' : undefined,
|
|
||||||
}}
|
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
title={name}
|
title={name}
|
||||||
>
|
>
|
||||||
{imageUrl ? (
|
<div
|
||||||
useGridCrop ? (
|
className={poseClassName}
|
||||||
<div
|
style={{
|
||||||
className="pet-sprite-grid"
|
width: size,
|
||||||
style={{
|
height: size,
|
||||||
width: size,
|
overflow: useGridCrop || useFrameCrop ? 'hidden' : undefined,
|
||||||
height: size,
|
}}
|
||||||
backgroundImage: `url(${imageUrl})`,
|
>
|
||||||
backgroundRepeat: 'repeat-x',
|
{usableImageUrl ? (
|
||||||
backgroundSize: `${gridCols! * 100}% ${gridRows! * 100}%`,
|
useGridCrop ? (
|
||||||
backgroundPositionY: bgPosY,
|
<div
|
||||||
animation: cycleAnimation,
|
className="pet-sprite-grid"
|
||||||
imageRendering: 'auto',
|
style={{
|
||||||
}}
|
width: size,
|
||||||
/>
|
height: size,
|
||||||
) : useFrameCrop ? (
|
backgroundImage: `url(${usableImageUrl})`,
|
||||||
<img
|
backgroundRepeat: 'no-repeat',
|
||||||
src={imageUrl}
|
backgroundSize: `${gridCols! * 100}% ${gridRows! * 100}%`,
|
||||||
alt=""
|
backgroundPositionY: bgPosY,
|
||||||
draggable={false}
|
animation: cycleAnimation,
|
||||||
style={{
|
imageRendering: 'auto',
|
||||||
width: 'auto',
|
...Object.fromEntries(framePositions.map((position, index) => [
|
||||||
height: 'auto',
|
`--pet-frame-${index}-position`,
|
||||||
maxWidth: 'none',
|
position,
|
||||||
maxHeight: 'none',
|
])),
|
||||||
objectFit: 'none',
|
}}
|
||||||
transformOrigin: '0 0',
|
/>
|
||||||
transform: `scale(${size / frameWidth!})`,
|
) : useFrameCrop ? (
|
||||||
}}
|
<img
|
||||||
/>
|
src={usableImageUrl}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
style={{
|
||||||
|
width: 'auto',
|
||||||
|
height: 'auto',
|
||||||
|
maxWidth: 'none',
|
||||||
|
maxHeight: 'none',
|
||||||
|
objectFit: 'none',
|
||||||
|
transformOrigin: '0 0',
|
||||||
|
transform: `scale(${size / frameWidth!})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img src={usableImageUrl} alt="" draggable={false} />
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<img src={imageUrl} alt="" draggable={false} />
|
<div className="pet-sprite-fallback">
|
||||||
)
|
<span />
|
||||||
) : (
|
<span />
|
||||||
<div className="pet-sprite-fallback">
|
</div>
|
||||||
<span />
|
)}
|
||||||
<span />
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
161
ui/src/components/pets/ToolSpark.test.tsx
Normal file
161
ui/src/components/pets/ToolSpark.test.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import '../../test/dom-setup';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, render } from '@testing-library/react';
|
||||||
|
import { ToolSpark } from './ToolSpark';
|
||||||
|
import type { LastToolEvent } from '../../hooks/useJobStream';
|
||||||
|
|
||||||
|
function toolEvent(name: string, isError: boolean | null, callId: string): LastToolEvent {
|
||||||
|
return { name, isError, callId, ts: Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ToolSpark', () => {
|
||||||
|
it('U7: does not render a spark for the tool_use moment (isError === null)', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ToolSpark event={toolEvent('WebSearch', null, 'c0')} enabled reducedMotion={false} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.tool-spark-burst')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('U6: renders a search-ripple spark once the result resolves', () => {
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<ToolSpark event={toolEvent('WebSearch', null, 'c1')} enabled reducedMotion={false} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.tool-spark-burst')).toBeNull();
|
||||||
|
|
||||||
|
rerender(<ToolSpark event={toolEvent('WebSearch', false, 'c1')} enabled reducedMotion={false} />);
|
||||||
|
const burst = container.querySelector('.tool-spark-burst');
|
||||||
|
expect(burst).toBeInTheDocument();
|
||||||
|
expect(burst).toHaveAttribute('data-tool-kind', 'search');
|
||||||
|
expect(burst).toHaveClass('tool-spark-mode-ripple');
|
||||||
|
expect(burst).toHaveClass('tool-spark-success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('U6: renders a terminal-blink spark for Bash', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ToolSpark event={toolEvent('Bash', false, 'c2')} enabled reducedMotion={false} />,
|
||||||
|
);
|
||||||
|
const burst = container.querySelector('.tool-spark-burst');
|
||||||
|
expect(burst).toHaveAttribute('data-tool-kind', 'terminal');
|
||||||
|
expect(burst).toHaveClass('tool-spark-mode-blink');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('U6: falls back to the default star spark for uncategorized tools', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ToolSpark event={toolEvent('SomeCustomTool', false, 'c3')} enabled reducedMotion={false} />,
|
||||||
|
);
|
||||||
|
const burst = container.querySelector('.tool-spark-burst');
|
||||||
|
expect(burst).toHaveAttribute('data-tool-kind', 'spark');
|
||||||
|
expect(burst).toHaveClass('tool-spark-mode-star');
|
||||||
|
expect(burst).not.toHaveClass('tool-spark-mode-ripple');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('U7: distinguishes success vs failure by color class and the "!" mark', () => {
|
||||||
|
const { container: successContainer } = render(
|
||||||
|
<ToolSpark event={toolEvent('Read', false, 'ok-1')} enabled reducedMotion={false} />,
|
||||||
|
);
|
||||||
|
expect(successContainer.querySelector('.tool-spark-burst')).toHaveClass('tool-spark-success');
|
||||||
|
expect(successContainer.querySelector('.tool-spark-burst')).not.toHaveClass('tool-spark-error');
|
||||||
|
expect(successContainer.querySelector('.tool-spark-error-mark')).toBeNull();
|
||||||
|
|
||||||
|
const { container: errorContainer } = render(
|
||||||
|
<ToolSpark event={toolEvent('Read', true, 'err-1')} enabled reducedMotion={false} />,
|
||||||
|
);
|
||||||
|
expect(errorContainer.querySelector('.tool-spark-burst')).toHaveClass('tool-spark-error');
|
||||||
|
expect(errorContainer.querySelector('.tool-spark-burst')).not.toHaveClass('tool-spark-success');
|
||||||
|
expect(errorContainer.querySelector('.tool-spark-error-mark')).toHaveTextContent('!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('U7: reducedMotion keeps only the color/mark — no particles, no per-kind motion class', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ToolSpark event={toolEvent('Bash', true, 'rm-1')} enabled reducedMotion={true} />,
|
||||||
|
);
|
||||||
|
const burst = container.querySelector('.tool-spark-burst');
|
||||||
|
expect(burst).toHaveClass('tool-spark-error');
|
||||||
|
expect(burst?.className ?? '').not.toMatch(/tool-spark-mode-/);
|
||||||
|
expect(container.querySelector('.tool-spark-particle')).toBeNull();
|
||||||
|
expect(container.querySelector('.tool-spark-error-mark')).toHaveTextContent('!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('U8: coalesces rapid results within the window into a ×N combo badge', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<ToolSpark event={toolEvent('Read', false, 'a1')} enabled reducedMotion={false} />,
|
||||||
|
);
|
||||||
|
const initialBurst = container.querySelector('.tool-spark-burst');
|
||||||
|
const initialParticle = container.querySelector('.tool-spark-particle');
|
||||||
|
// A single result never shows a combo count.
|
||||||
|
expect(container.querySelector('.tool-spark-combo')).toBeNull();
|
||||||
|
|
||||||
|
act(() => { vi.advanceTimersByTime(200); });
|
||||||
|
rerender(<ToolSpark event={toolEvent('Read', false, 'a2')} enabled reducedMotion={false} />);
|
||||||
|
expect(container.querySelector('.tool-spark-combo')).toHaveTextContent('×2');
|
||||||
|
expect(container.querySelector('.tool-spark-burst')).toBe(initialBurst);
|
||||||
|
expect(container.querySelector('.tool-spark-particle')).toBe(initialParticle);
|
||||||
|
|
||||||
|
act(() => { vi.advanceTimersByTime(200); });
|
||||||
|
rerender(<ToolSpark event={toolEvent('Grep', false, 'a3')} enabled reducedMotion={false} />);
|
||||||
|
expect(container.querySelector('.tool-spark-combo')).toHaveTextContent('×3');
|
||||||
|
expect(container.querySelector('.tool-spark-burst')).toBe(initialBurst);
|
||||||
|
expect(container.querySelector('.tool-spark-particle')).toBe(initialParticle);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('U8: resets the combo once the coalesce window elapses without a new result', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<ToolSpark event={toolEvent('Read', false, 'b1')} enabled reducedMotion={false} />,
|
||||||
|
);
|
||||||
|
act(() => { vi.advanceTimersByTime(200); });
|
||||||
|
rerender(<ToolSpark event={toolEvent('Read', false, 'b2')} enabled reducedMotion={false} />);
|
||||||
|
expect(container.querySelector('.tool-spark-combo')).toHaveTextContent('×2');
|
||||||
|
|
||||||
|
// Let the 600ms coalesce window lapse before the next result arrives.
|
||||||
|
act(() => { vi.advanceTimersByTime(700); });
|
||||||
|
rerender(<ToolSpark event={toolEvent('Read', false, 'b3')} enabled reducedMotion={false} />);
|
||||||
|
expect(container.querySelector('.tool-spark-combo')).toBeNull();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('U8: promotes a coalesced combo to error without restarting its particles', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<ToolSpark event={toolEvent('Read', false, 'error-combo-1')} enabled reducedMotion={false} />,
|
||||||
|
);
|
||||||
|
const initialBurst = container.querySelector('.tool-spark-burst');
|
||||||
|
const initialParticle = container.querySelector('.tool-spark-particle');
|
||||||
|
|
||||||
|
act(() => { vi.advanceTimersByTime(200); });
|
||||||
|
rerender(<ToolSpark event={toolEvent('Grep', true, 'error-combo-2')} enabled reducedMotion={false} />);
|
||||||
|
|
||||||
|
const burst = container.querySelector('.tool-spark-burst');
|
||||||
|
expect(burst).toBe(initialBurst);
|
||||||
|
expect(container.querySelector('.tool-spark-particle')).toBe(initialParticle);
|
||||||
|
expect(burst).toHaveClass('tool-spark-error');
|
||||||
|
expect(container.querySelector('.tool-spark-error-mark')).toHaveTextContent('!');
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the spark after the hold duration with no further results', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const { container } = render(
|
||||||
|
<ToolSpark event={toolEvent('Read', false, 'hold-1')} enabled reducedMotion={false} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.tool-spark-burst')).toBeInTheDocument();
|
||||||
|
act(() => { vi.advanceTimersByTime(3300); });
|
||||||
|
expect(container.querySelector('.tool-spark-burst')).toBeNull();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user