sync: update from private repo (9a86f49b)
All checks were successful
CI / build-and-test (push) Successful in 9m8s
CI / build-and-test (pull_request) Successful in 9m42s

This commit is contained in:
oss-sync 2026-07-12 23:51:48 +00:00
parent a67c40d33c
commit ed3dc5529a
159 changed files with 11696 additions and 1600 deletions

View File

@ -8,6 +8,16 @@ follow semantic versioning.
### 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
date-stamped outputs (e.g. `output/report-2026-07-09.md`,
`output/deepdive/2026-07-09/`) so repeated runs in a persistent workspace
@ -24,6 +34,15 @@ follow semantic versioning.
### 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)
regardless of actual activity; tool events now carry their own run
attribution (2026-07-09).

4
logs/bash-history.jsonl Normal file
View 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}

View File

@ -22,6 +22,7 @@ exports[`createCoreServer route registration order > pins the auth-active + Conf
"USE name=requireAdmin path=^\\/api\\/config\\/?(?=\\/|$)",
"USE name=requireAuth path=^\\/api\\/pieces\\/?(?=\\/|$)",
"USE name=requireAuth path=^\\/api\\/usage\\/?(?=\\/|$)",
"USE name=requireAuth path=^\\/api\\/llm\\/workers\\/?(?=\\/|$)",
"USE name=requireAuth path=^\\/api\\/calendar\\/?(?=\\/|$)",
"USE name=requireAuth path=^\\/api\\/scheduled-tasks\\/?(?=\\/|$)",
"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/evaluate-prompt",
"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/content",
"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\\/usage\\/?(?=\\/|$)",
"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/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/evaluate-prompt",
"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/content",
"ROUTE get /api/local/tasks/:taskId/files/provenance",

View File

@ -20,11 +20,19 @@ import { createUserDelegationsRouter, createAdminDelegationsRouter } from './a2a
* `a2a.enabled: true` in config.yaml. Registration order relative to the
* 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(
app: express.Express,
deps: { repo: Repository; authActive: boolean },
): void {
): A2aSubsystemResult {
const { repo, authActive } = deps;
let limiter: A2aLimiter | undefined;
// --- A2A OAuth2 Authorization Server ---
const a2aCfg = (loadConfig() as any).a2a ?? {};
@ -54,6 +62,7 @@ export function setupA2aSubsystem(
// JSON-RPC と拡張カードRESTの両経路で 1 grant のレート予算を共有するため、
// limiter は 1 インスタンスをここで生成して両者に渡す。
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 }));
// JSON-RPC エンドポイント /a2aSDK DefaultRequestHandler 経由)
// /a2a は /api 配下でないため requireAuth に掛からない。認証は UserBuilder が担う。
@ -73,6 +82,8 @@ export function setupA2aSubsystem(
logger.info('[a2a-oidc] a2a authorization server enabled');
} catch (err) {
logger.warn(`[a2a-oidc] failed to mount a2a authorization server: ${err}`);
limiter = undefined;
}
}
return { limiter };
}

View File

@ -161,6 +161,9 @@ export function mountAuthPipeline(
// is enforced inside pieces-api.ts handlers.
app.use('/api/pieces', requireAuth);
app.use('/api/usage', requireAuth);
// LLM ワーカー選択 APIタスクへの pin 用): 秘密値は含まないが、未認証の
// 列挙は不要なので他の /api/* と同様 requireAuth で揃える。
app.use('/api/llm/workers', requireAuth);
// 横断カレンダー: 認証時はログイン必須。可視スペースの絞り込みは
// Repository.getCrossSpaceCalendarMonth が viewer 単位で行う。
app.use('/api/calendar', requireAuth);

View 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);
});
});

View 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;
}

View 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();
});
});

View 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);
}

View 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;
}

View 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();
});
});

View 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)');
}

View 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);
});
});

View 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;
}
}
}

View 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 });
});
});

View 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|quality1
* config title/reflection
*
*
* endpoint / apiKey / extraBody UI
* GET /api/workersconfig-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 APITask 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 };
}

View File

@ -1289,6 +1289,18 @@ describe('POST /api/local/tasks/:id/continue', () => {
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 () => {
// jobs.status CHECK constraint permits these four terminal states.
// 'aborted' is intentionally absent — the worker maps abort outcomes to

View File

@ -8,6 +8,7 @@ import { registerLocalTaskToolRequestRoutes } from './local-tasks-tool-requests-
import { registerLocalTaskPackageRequestRoutes } from './local-tasks-package-requests-api.js';
import { registerLocalTaskControlRoutes } from './local-tasks-control-api.js';
import { registerLocalTaskStreamRoutes } from './local-tasks-stream-api.js';
import { registerLocalTaskMovementHistoryRoutes } from './local-tasks-movement-history-api.js';
export interface LocalTasksApiOptions {
repo: Repository;
@ -69,6 +70,15 @@ export interface LocalTasksApiOptions {
* approving a package request fails with 400 (feature disabled).
*/
getPythonPackagesConfig?: () => import('../config.js').PythonPackagesConfigShape | undefined;
/**
* LLM Phase 1: タスク作成 / PATCH llmWorkerIdllmEffort
* 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);
registerLocalTaskControlRoutes(app, deps);
registerLocalTaskStreamRoutes(app, deps);
registerLocalTaskMovementHistoryRoutes(app, deps);
}

View File

@ -204,6 +204,13 @@ export function registerLocalTaskCommentsRoutes(app: Application, deps: LocalTas
visibilityScopeOrgId: task!.visibilityScopeOrgId,
browserSessionProfileId: task!.browserSessionProfileId ?? 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 });
// Blocked by a tool/package-approval pause → return BEFORE persisting the
// comment so a rejected post leaves no orphan comment tied to no job.

View File

@ -114,6 +114,11 @@ export function registerLocalTaskControlRoutes(app: Application, deps: LocalTask
visibilityScopeOrgId: task!.visibilityScopeOrgId,
browserSessionProfileId: task!.browserSessionProfileId ?? 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 });

View 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 200task 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');
});
});

View File

@ -10,6 +10,7 @@ import { buildTitleFallback } from '../title-generation.js';
import { resolveTaskWorkspaceDir, ensureWorkspaceDirs } from '../spaces/workspace-resolver.js';
import { spaceRunsDir } from '../spaces/runtime-paths.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.
@ -82,6 +83,27 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe
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 rawPiece = (body.piece ?? 'auto').trim();
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,
browserSessionProfileId,
options: taskOptions,
llmWorkerId,
llmEffort,
});
// 実効スペースを解決し、mode に応じてワークスペースパスを決める。
@ -224,6 +248,8 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe
browserSessionProfileId: task.browserSessionProfileId ?? null,
spaceId: task.spaceId ?? null,
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 });
@ -265,7 +291,14 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
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 (typeof req.body.title !== 'string') {
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') {
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);
const refreshed = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
if ((updates.visibility !== undefined || updates.visibilityScopeOrgId !== undefined) && refreshed) {
@ -306,6 +379,16 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe
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 });
} catch (err) {
logger.error(`Patch local task API error: ${err}`);

View 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([]);
});
});

View 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' });
}
});
};

View File

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import request from 'supertest';
@ -28,6 +28,7 @@ describe('createCoreServer subsystem wiring', () => {
afterEach(() => {
delete process.env.MCP_ENCRYPTION_KEY;
delete process.env.AAO_CONFIG;
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
@ -57,6 +58,20 @@ describe('createCoreServer subsystem wiring', () => {
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 () => {
const { app } = createCoreServer({ repo, worktreeDir: join(tempDir, 'wt') });
const res = await request(app).get('/api/local/tasks');

View File

@ -18,6 +18,7 @@ import { createBrowserSessionApi } from './browser-session-api.js';
import { createSubtaskActivityRouter } from './subtask-activity-api.js';
import { createDelegateRunsRouter } from './delegate-runs-api.js';
import { createUsageRouter } from './usage-api.js';
import { createLlmWorkersRouter } from './llm-workers-api.js';
import { SessionManager } from '../engine/browser-session.js';
import { setupNovncWebSocketProxy } from './novnc-proxy.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 { mountBridgeGateway } from './bridge-gateway-mount.js';
import { setupA2aSubsystem } from './a2a-subsystem.js';
import { setupChatSubsystem } from './chat/chat-subsystem.js';
import { createServer as createHttpsServer } from 'https';
import { X509Certificate } from 'crypto';
import { mergeServerConfig, resolveListenPort } from '../server/config.js';
@ -282,6 +284,10 @@ export function createCoreServer(opts: CoreServerOptions): {
: () => loadConfig().tools?.taskUploadMaxSizeMb ?? 50,
// Package-request approvals install into the task's space overlay.
getPythonPackagesConfig: () => loadConfig().pythonPackages,
// LLM 選択 Phase 1: タスク作成/PATCH の validateLlmSelection に渡す worker 一覧。
getLlmWorkers: opts.configManager
? () => opts.configManager!.getConfig().provider?.workers ?? []
: () => loadConfig().provider?.workers ?? [],
});
// --- Local files API ---
@ -294,8 +300,20 @@ export function createCoreServer(opts: CoreServerOptions): {
// 横断(全スペース)カレンダー: GET /api/calendar/cross
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) ---
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) ---
mountSubtaskFilesApi(app, repo);

View File

@ -306,11 +306,16 @@ export function mountSetupApi(
// 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.
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) {
logger.warn(`[setup-api] status failed: ${String(e)}`);
// Fail-closed: on error the UI treats a2a as disabled and hides the section.
res.status(500).json({ needsSetup: false, authActive, a2aEnabled: false, error: 'status unavailable' });
// Fail-closed: on error the UI treats a2a/chat as disabled and hides the sections.
res.status(500).json({ needsSetup: false, authActive, a2aEnabled: false, chatEnabled: false, error: 'status unavailable' });
}
});

View File

@ -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', () => {
it('creates a span and surfaces it on every covered day in the month view', async () => {
const created = await request(appAs(owner))

View File

@ -152,6 +152,9 @@ export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps):
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' });
}
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 ev = await repo.createCalendarEvent({
spaceId: space.id,
@ -160,6 +163,7 @@ export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps):
endDate,
time,
endTime,
reminderMinutes,
title,
description: req.body?.description != null ? String(req.body.description) : null,
createdBy: 'user',
@ -179,7 +183,7 @@ export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps):
const eventId = Number(req.params.eventId);
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' });
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) {
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' });
@ -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' });
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 だけを変えて
// end_time を省略したケース(開始を遅らせる・複数日を単日に戻す等)でも、
// 単日で終了<開始や開始時刻なしの終了時刻という不整合を確実に弾く。
{
const effTime = patch.time !== undefined ? patch.time : existing.time;
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 (effTime == null) return res.status(400).json({ error: 'end_time requires a start time' });
const effEnd = patch.endDate !== undefined ? patch.endDate : existing.endDate;
@ -251,4 +264,16 @@ export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps):
await repo.deleteCalendarEvent(eventId);
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 });
});
}

View File

@ -92,17 +92,19 @@ export function setupSshSubsystem(app: express.Application, deps: SshSubsystemDe
let sshConsole: SshConsoleDeps | null = null;
const sshConfig = mergeSshConfig(loadConfig().ssh);
if (!sshConfig.enabled) {
const sshAvailable = sshConfig.enabled && isKeyConfigured();
if (!sshAvailable) {
setSshSubsystem(null);
__setActiveSessionLookup(null);
// SSH is off: the real console/status router (and the registry /
// resolveTask / resolveSshAccess it needs) never gets mounted below,
// 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
// DevTools console (issue #785). Mount a minimal stub that always
// answers `{ active: false }` — scope note: the sibling
// `!isKeyConfigured()` branch below has the SAME 404-polling problem
// but is deliberately left untouched here (separate issue).
// DevTools console (issues #785, #813). Mount a minimal stub that always
// answers `{ active: false }`.
if (sshConfig.enabled) {
logger.warn('[ssh] MCP_ENCRYPTION_KEY not configured — SSH features disabled');
}
app.use(
'/api',
createConsoleStatusStubRouter({
@ -110,10 +112,6 @@ export function setupSshSubsystem(app: express.Application, deps: SshSubsystemDe
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 {
try {
bootstrapSystemDek(repo.getDb());

View File

@ -540,6 +540,27 @@ export interface WebhooksConfig {
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 {
/**
* Schema version. `2` = v2 layout (`llm.*` / `storage.*`). Missing or `1`
@ -616,6 +637,7 @@ export interface AppConfig {
maxConcurrentResubscribe?: number;
};
};
chat?: ChatConfig;
}
const DEFAULT_REFLECTION: ReflectionConfig = {

View 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();
}
});
});

View 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 });
}
});
});

View File

@ -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'");
});
// 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);
migrateSshTables(db);
migrateDashboardWidgets(db);
@ -233,6 +249,7 @@ export function runMigrations(db: Database.Database): void {
migrateSpaceUserPrefs(db);
migrateSpaceMembers(db);
migrateCalendarEvents(db);
migrateAgentReminders(db);
migrateSpaceInvites(db);
migrateAppShareLinks(db);
migratePrivatizeSpaceRows(db);
@ -245,6 +262,7 @@ export function runMigrations(db: Database.Database): void {
migrateBackfillTaskCommentIndex(db);
migrateA2aTaskDelegationColumns(db);
migrateSpaceWebhooksTables(db);
migrateChatConnectorBindings(db);
}
/**
@ -523,6 +541,23 @@ function migrateCalendarEvents(db: Database.Database): void {
addColumnIfMissing(db, 'calendar_events', 'end_time', () => {
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');
});
}
/**
* 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);
`);
}

View File

@ -24,6 +24,8 @@ export interface CalendarEvent {
endDate: string | null; // 終了日 YYYY-MM-DD。null = date と同じ(単日)
time: string | null; // 開始 HH:MM。null = 終日
endTime: string | null; // 終了 HH:MM。null = 終了時刻なしtime が null なら常に null
reminderMinutes: number | null;
reminderDeliveredAt: string | null;
title: string;
description: string | null;
createdBy: 'user' | 'agent';
@ -41,6 +43,8 @@ export interface CalendarEventRow {
end_date: string | null;
time: string | null;
end_time: string | null;
reminder_minutes: number | null;
reminder_delivered_at: string | null;
title: string;
description: string | null;
created_by: string;
@ -80,6 +84,8 @@ export function rowToCalendarEvent(row: CalendarEventRow): CalendarEvent {
endDate: row.end_date ?? null,
time: row.time ?? null,
endTime: row.end_time ?? null,
reminderMinutes: row.reminder_minutes ?? null,
reminderDeliveredAt: row.reminder_delivered_at ?? null,
title: row.title,
description: row.description ?? null,
createdBy: row.created_by === 'agent' ? 'agent' : 'user',
@ -145,6 +151,7 @@ export interface CrossSpaceCalendarMonth {
endDate?: string | null;
time?: string | null;
endTime?: string | null;
reminderMinutes?: number | null;
title: string;
description?: string | null;
createdBy?: 'user' | 'agent';
@ -157,8 +164,8 @@ export interface CrossSpaceCalendarMonth {
const endTime = time ? (params.endTime ?? null) : null;
const result = db
.prepare(
`INSERT INTO calendar_events (space_id, owner_id, date, end_date, time, end_time, title, description, created_by, source_task_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
`INSERT INTO calendar_events (space_id, owner_id, date, end_date, time, end_time, reminder_minutes, title, description, created_by, source_task_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
params.spaceId,
@ -167,6 +174,7 @@ export interface CrossSpaceCalendarMonth {
endDate,
time,
endTime,
params.reminderMinutes ?? null,
params.title,
params.description ?? null,
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 args: unknown[] = [];
if (fields.date !== undefined) {
@ -244,6 +252,13 @@ export interface CrossSpaceCalendarMonth {
sets.push('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);
sets.push(`updated_at = datetime('now')`);
args.push(eventId);
@ -256,6 +271,31 @@ export interface CrossSpaceCalendarMonth {
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}

View 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());
});
});

View 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;
}

View 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);
});
});
});

View 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();
});
});

View File

@ -62,6 +62,17 @@ export interface Job {
requiredRole: JobRole;
/** @deprecated Use requiredRole */
requiredProfile: JobRole;
/**
* LLM Phase 1: pinned worker id for this job. NULL =
* profile /
* nullclaim 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;
visibility: 'private' | 'org' | 'public';
visibilityScopeOrgId: string | null;
@ -104,6 +115,10 @@ export interface CreateJobParams {
spaceId?: string | null;
/** 実行ログ root計画5。NULL = 後方互換で workspacePath/logs に解決。 */
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;
required_profile: string;
task_class: string;
required_worker_id: string | null;
reasoning_effort: string | null;
owner_id: string | null;
visibility: string | null;
visibility_scope_org_id: string | null;
@ -209,6 +226,8 @@ export function rowToJob(row: JobRow): Job {
subtaskDepth: row.subtask_depth ?? 0,
requiredRole: 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,
visibility: (row.visibility === 'org' || row.visibility === 'public' ? row.visibility : 'private'),
visibilityScopeOrgId: row.visibility_scope_org_id ?? null,
@ -243,8 +262,8 @@ export function rowToJob(row: JobRow): Job {
db
.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)
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)`
`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, @requiredWorkerId, @reasoningEffort, @now, @now)`
)
.run({
id,
@ -267,6 +286,8 @@ export function rowToJob(row: JobRow): Job {
payload: params.payload ?? null,
spaceId: params.spaceId ?? null,
runtimeDir: params.runtimeDir ?? null,
requiredWorkerId: params.requiredWorkerId ?? null,
reasoningEffort: params.reasoningEffort ?? null,
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> {
const row = db.prepare(`
UPDATE jobs
@ -575,9 +617,7 @@ export function rowToJob(row: JobRow): Job {
FROM jobs j
JOIN worker_nodes w ON w.worker_id = ?
WHERE j.status = 'queued'
AND w.enabled = 1
AND w.healthy = 1
AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0
AND ${CLAIMABLE_MATCH_SQL}
AND NOT EXISTS (
SELECT 1 FROM issue_locks il
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 = ?
WHERE j.status = 'retry'
AND replace(j.next_retry_at, 'T', ' ') <= datetime('now')
AND w.enabled = 1
AND w.healthy = 1
AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0
AND ${CLAIMABLE_MATCH_SQL}
AND NOT EXISTS (
SELECT 1 FROM issue_locks il
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 = ?
WHERE j.status = 'retry'
AND replace(j.next_retry_at, 'T', ' ') <= datetime('now')
AND w.enabled = 1
AND w.healthy = 1
AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0
AND ${CLAIMABLE_MATCH_SQL}
AND NOT EXISTS (
SELECT 1 FROM issue_locks il
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
JOIN worker_nodes w ON w.worker_id = ?
WHERE j.status = 'queued'
AND w.enabled = 1
AND w.healthy = 1
AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0
AND ${CLAIMABLE_MATCH_SQL}
AND NOT EXISTS (
SELECT 1 FROM issue_locks il
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 /
* retryrunning/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[]> {
// 同一 issue_number に複数ジョブがある場合ASK再投入等、最新のみ返す
// ROW_NUMBER() + rowid で同一 created_at でも一意に決定する

View File

@ -69,6 +69,14 @@ export interface LocalTask {
spaceId: string | null;
/** 実行ログ root計画5。null = 後方互換で workspacePath/logs に解決。 */
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;
updatedAt: string;
feedbackRating: 'good' | 'bad' | null;
@ -162,6 +170,13 @@ export interface CreateLocalTaskParams {
spaceId?: string | null;
/** Per-task runtime options (e.g. { mcpDisabled, skillsDisabled }). Stored as JSON. */
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_mode: string | null;
runtime_dir: string | null;
llm_worker_id: string | null;
llm_effort: string | null;
owner_id: string | null;
owner_name?: string | null;
visibility: string | null;
@ -232,6 +249,8 @@ export function rowToLocalTask(row: LocalTaskRow): LocalTask {
visibilityScopeOrgName: row.visibility_scope_org_name ?? null,
spaceId: row.space_id ?? null,
runtimeDir: row.runtime_dir ?? null,
llmWorkerId: row.llm_worker_id ?? null,
llmEffort: row.llm_effort ?? null,
createdAt: utc(row.created_at),
updatedAt: utc(row.updated_at),
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> {
const result = db
.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)
VALUES (@title, @titleSource, @body, @pieceName, @profile, @outputFormat, @askPolicy, @priority, @workspacePath, @workspaceMode, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @spaceId, @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, @llmWorkerId, @llmEffort)`
)
.run({
title: params.title,
@ -349,6 +368,8 @@ export function parseCommentAttachments(raw: string | null): string[] {
browserSessionProfileId: params.browserSessionProfileId ?? null,
spaceId: params.spaceId ?? null,
options: JSON.stringify(params.options ?? {}),
llmWorkerId: params.llmWorkerId ?? null,
llmEffort: params.llmEffort ?? null,
});
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,
required_profile: row.job_required_profile!,
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,
visibility: row.job_visibility ?? 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',
visibility: 'visibility',
visibilityScopeOrgId: 'visibility_scope_org_id',
llmWorkerId: 'llm_worker_id',
llmEffort: 'llm_effort',
};
for (const [jsKey, dbCol] of Object.entries(fieldMap)) {

View 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;
}

View File

@ -159,6 +159,8 @@ const __dirname = dirname(__filename);
end_date TEXT,
time TEXT,
end_time TEXT,
reminder_minutes INTEGER,
reminder_delivered_at TEXT,
title TEXT NOT NULL,
description TEXT,
created_by TEXT NOT NULL DEFAULT 'user',
@ -167,6 +169,13 @@ const __dirname = dirname(__filename);
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 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 と三重ミラー。
@ -470,6 +479,14 @@ const __dirname = dirname(__filename);
CREATE INDEX IF NOT EXISTS idx_llm_usage_hourly_user_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');
}

View File

@ -39,6 +39,16 @@ describe('Repository calendar_events', () => {
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 () => {
const ev = await repo.createCalendarEvent({ spaceId: 'space-A', date: '2026-06-20', title: 'allday' });
expect(ev.createdBy).toBe('user');

View 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();
});
});

View 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);
});
});

View File

@ -24,6 +24,8 @@ import * as notificationsRepo from './repositories/notifications.js';
import { PushSubscriptionRecord, UpsertPushSubscriptionInput, NotificationPrefs, NotificationPrefsUpdate } from './repositories/notifications.js';
import * as webhooksRepo 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 appSharesRepo from './repositories/app-shares.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 calendarRepo 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 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 { 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 { GatewayVirtualKeySource, GatewayVirtualKey, GatewayKeyUsage } from './repositories/gateway.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);
}
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[]> {
return jobsRepo.getSubJobs(this.db, parentJobId);
}
@ -842,6 +852,7 @@ export class Repository {
endDate?: string | null;
time?: string | null;
endTime?: string | null;
reminderMinutes?: number | null;
title: string;
description?: string | null;
createdBy?: 'user' | 'agent';
@ -932,7 +943,7 @@ export class Repository {
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);
}
@ -940,6 +951,22 @@ export class Repository {
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[] } {
return calendarRepo.calendarTaskScope(spaceId, personalOwnerId, alias);
}
@ -1130,6 +1157,43 @@ export class Repository {
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 {
this.db.close();
}

View File

@ -34,6 +34,10 @@ CREATE TABLE IF NOT EXISTS jobs (
space_id TEXT,
-- 実行ログ root計画5。NULL = 後方互換で workspace_path/logs に解決。
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')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
@ -82,7 +86,13 @@ CREATE TABLE IF NOT EXISTS local_tasks (
-- 実行先ワークスペースの種別。'persistent'(既定) / 'ephemeral'(使い捨て)計画3
workspace_mode TEXT NOT NULL DEFAULT 'persistent',
-- 実行ログ 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);
@ -156,6 +166,8 @@ CREATE TABLE IF NOT EXISTS calendar_events (
end_date TEXT, -- 終了日 YYYY-MM-DD。NULL = date と同じ(単日)
time TEXT, -- 開始 HH:MM。NULL = 終日
end_time TEXT, -- 終了 HH:MM。NULL = 終了時刻なしtime が NULL なら常に NULL
reminder_minutes INTEGER, -- 開始何分前に通知するか。NULL = 通知なし
reminder_delivered_at TEXT, -- 配信済み UTC。予定/通知設定変更時に NULL へ戻す
title TEXT NOT NULL,
description TEXT,
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 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 が発行し、リンクを知る相手が参加する。
-- スペースごとに有効リンクは最大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);
-- 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
-- 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".

View File

@ -34,6 +34,36 @@ describe('calendar tools', () => {
it('exposes TOOL_DEFS entries', () => {
expect(TOOL_DEFS.AddCalendarEvent?.function.name).toBe('AddCalendarEvent');
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 () => {

View File

@ -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 MAX_TITLE_LEN = 200;
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;
@ -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> = {
AddCalendarEvent: ADD_CALENDAR_EVENT_DEF,
ListCalendarEvents: LIST_CALENDAR_EVENTS_DEF,
CreateReminder: CREATE_REMINDER_DEF,
ListReminders: LIST_REMINDERS_DEF,
CancelReminder: CANCEL_REMINDER_DEF,
};
export async function executeTool(
@ -63,9 +91,57 @@ export async function executeTool(
): Promise<ToolResult | null> {
if (name === 'AddCalendarEvent') return executeAddCalendarEvent(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;
}
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(
input: Record<string, unknown>,
ctx: ToolContext,

View 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);
});
});

View 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,
};
}

View File

@ -193,4 +193,49 @@ describe('executeJob pre-flight gates (behaviour pinning)', () => {
expect(updateJobCalls.some(([, patch]) => patch.status === 'waiting_human')).toBe(false);
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();
});
});

View File

@ -111,4 +111,53 @@ describe('Worker LLM client construction — extraBody propagation', () => {
const options = constructorSpy.mock.calls[0]?.[8];
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' });
});
});

View File

@ -65,6 +65,8 @@ describe('inheritJobContext (derived-job session inheritance)', () => {
visibilityScopeOrgId: 'org-9',
spaceId: 'case-42',
browserSessionProfileId: null,
requiredWorkerId: null,
reasoningEffort: null,
});
});

View 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');
});
});

View 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 });
});
});

View File

@ -5,6 +5,7 @@ import { assertProfileOwner } from './engine/browser-session-auth.js';
import { decryptProfileState } from './crypto/profile-dek.js';
import { OpenAICompatClient } from './llm/openai-compat.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 { LocalProgressReporter } from './progress/local-reporter.js';
import { buildLocalConversationContext } from './engine/local-context.js';
@ -241,6 +242,8 @@ export function inheritJobContext(job: Job): {
visibilityScopeOrgId: string | null;
spaceId: string | null;
browserSessionProfileId: number | null;
requiredWorkerId: string | null;
reasoningEffort: string | null;
} {
return {
ownerId: job.ownerId,
@ -248,6 +251,11 @@ export function inheritJobContext(job: Job): {
visibilityScopeOrgId: job.visibilityScopeOrgId,
spaceId: job.spaceId ?? 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) {
const peek = await this.repo.peekNextClaimable(this.workerId);
if (peek && peek.id !== this.lastYieldedJobId) {
const idler = this.findIdlerCompetitor(peek.requiredRole);
const idler = peek.requiredWorkerId ? null : this.findIdlerCompetitor(peek.requiredRole);
if (idler) {
this.lastYieldedJobId = peek.id;
idler.pokePoll();
@ -1099,7 +1107,7 @@ export class Worker {
{
proxy: workerDefForAnswer.proxy === true,
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> {
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 (
shouldRequeueForModelMismatch({
isGateway: this.getWorkerDef().proxy === true,
@ -1481,7 +1496,7 @@ export class Worker {
const resolvedModel = llmRoutingKey({
isGateway: isProxyWorker,
role: job.requiredRole,
resolveDirectModel: () => this.resolveModel(piece),
resolveDirectModel: () => this.resolveModel(piece, { pinned: !!job.requiredWorkerId }),
});
const timeoutMs = (this.config.provider.timeoutMinutes ?? 10) * 60 * 1000;
const llmClient = new OpenAICompatClient(
@ -1497,7 +1512,7 @@ export class Worker {
proxy: isProxyWorker,
maxStreamMs: this.resolveMaxStreamMs(),
requestPromptProgress: workerDefForLlm.returnProgress === true,
extraBody: workerDefForLlm.extraBody,
extraBody: buildJobExtraBody(workerDefForLlm, job.reasoningEffort),
},
);
return { llmClient, isProxyWorker };
@ -3146,8 +3161,8 @@ export class Worker {
}
}
private resolveModel(piece: PieceDef): string | undefined {
if (piece.model) {
private resolveModel(piece: PieceDef, opts?: { pinned?: boolean }): string | undefined {
if (piece.model && !opts?.pinned) {
if (this.availableModels.size === 0 || this.availableModels.has(piece.model)) {
return piece.model;
}
@ -3170,6 +3185,27 @@ export class Worker {
): Promise<'requeued_unhealthy' | 'retry' | 'failed'> {
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);
if (isLlmConnectionFatal) {
this.healthy = false;
@ -3195,6 +3231,11 @@ export class Worker {
nextRetryAt: null,
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`);
return 'requeued_unhealthy';
}
@ -3229,6 +3270,11 @@ export class Worker {
nextRetryAt,
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}`);
return 'retry';
} else {

View File

@ -4,8 +4,11 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSetupState } from './hooks/useSetupState';
import { SetupWizard } from './components/setup/SetupWizard';
import { createLocalTask, fetchLocalTask, type CreateLocalTaskInput, type Visibility } from './api';
import { claimSpaceCalendarReminders } from './api/calendar';
import { useUrlState } from './hooks/useUrlState';
import { useToast } from './hooks/useToast';
import { ToastHost } from './components/notifications/ToastHost';
import { ToastPet } from './components/notifications/ToastPet';
import { useLocalTaskList } from './hooks/useTaskList';
import { useBranding } from './hooks/useBranding';
import { useLocalStorageState } from './hooks/useLocalStorageState';
@ -182,11 +185,36 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
// 個人スペースを所有するので user=null でも従来通り解決する。
// 一覧が空/ローディング中なら personalSpaceId は nullgraceful skeleton
const { data: spaces } = useSpaces();
const { toasts, showToast, dismissToast } = useToast();
const [settingsSpaceId, setSettingsSpaceId] = useState<string | null>(null);
const personalSpaceId =
spaces?.find(
s => s.kind === 'personal' && (user == null || s.ownerId === user.id),
)?.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
// 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
@ -298,9 +326,6 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
*/
const [createInitialPiece, setCreateInitialPiece] = useState<string | null>(null);
// Toast
const { toast, showToast } = useToast();
// URL sync. While a legacy Tasks deep link is still pending normalization,
// skip the push: otherwise this would pushState the parsed fallback (page=tasks
// → spaces) and pollute history *before* the legacy redirect's replaceState
@ -450,6 +475,16 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
onNotificationClick: (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
@ -509,20 +544,10 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
onCompactChange={setCompactMode}
/>
<div role="status" aria-live="polite" aria-atomic="true" className="flex-shrink-0">
{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>
<ToastHost toasts={toasts} onDismiss={dismissToast} />
{page === 'settings' && <div className="flex-1 min-h-0 overflow-hidden"><SettingsPage isAdmin={isAdmin} /></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 === '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} 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 === '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>}
@ -558,4 +583,3 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
</div>
);
}

View File

@ -24,3 +24,4 @@ export * from './api/skills';
export * from './api/notifications';
export * from './api/usage';
export * from './api/delegate';
export * from './api/chat-connectors';

View File

@ -12,6 +12,8 @@ export interface CalendarEvent {
endDate: string | null; // 終了日 YYYY-MM-DD。null = 単日
time: string | null; // 開始 HH:MM。null = 終日
endTime: string | null; // 終了 HH:MM。null = 終了時刻なしtime が null なら常に null
reminderMinutes: number | null;
reminderDeliveredAt: string | null;
title: string;
description: string | null;
createdBy: 'user' | 'agent';
@ -102,13 +104,13 @@ export async function fetchSpaceCalendarDay(
export async function createCalendarEvent(
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> {
const { endDate, endTime, ...rest } = input;
const { endDate, endTime, reminderMinutes, ...rest } = input;
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events`, {
method: 'POST',
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();
if (!res.ok) throw new Error(data?.error ?? 'Failed to create event');
@ -118,12 +120,13 @@ export async function createCalendarEvent(
export async function updateCalendarEvent(
spaceId: string,
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> {
const { endDate, endTime, ...rest } = patch;
const { endDate, endTime, reminderMinutes, ...rest } = patch;
const body: Record<string, unknown> = { ...rest };
if (endDate !== undefined) body.end_date = endDate;
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}`, {
method: 'PATCH',
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);
}
}
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[];
}

View 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);
}

View File

@ -52,6 +52,14 @@ export interface LocalTask {
visibilityScopeOrgName?: string | null;
/** Spaces foundation: which space this task belongs to (null = legacy/個人). */
spaceId?: string | null;
/**
* LLM Phase 1: sticky (null/undefined)
*
*
*/
llmWorkerId?: string | null;
/** llmWorkerId とセットの場合のみ有効な reasoning effort。 */
llmEffort?: string | null;
createdAt: string;
updatedAt: string;
latestJob?: {
@ -211,6 +219,26 @@ export interface LocalTaskComment {
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 {
title?: string;
body: string;
@ -230,6 +258,13 @@ export interface CreateLocalTaskInput {
workspaceMode?: 'persistent' | 'ephemeral';
/** 紐付けるスペース。未指定なら owner の個人スペースに解決される。 */
spaceId?: string;
/**
* LLM Phase 1: タスクをピン留めする実行ワーカー/null
*
*/
llmWorkerId?: string | null;
/** llmWorkerId とセットの場合のみ有効な reasoning effort。 */
llmEffort?: string | null;
options?: {
mcpDisabled?: boolean;
skillsDisabled?: boolean;
@ -297,6 +332,13 @@ export async function fetchLocalTaskComments(taskId: number): Promise<LocalTaskC
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> {
const payload: Record<string, unknown> = { body, author };
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(
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> {
const res = await fetch(`${BASE}/local/tasks/${taskId}`, {
method: 'PATCH',

View File

@ -44,6 +44,32 @@ export async function fetchWorkerBackends(workerId: string): Promise<WorkerBacke
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 ────────────────────────────────────────────────────────
export interface NodeStatus {

View 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">&times;</button>
</span>
))}
</div>
);
}

View 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}
/>
);
}

View 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>
);
}

View File

@ -4,6 +4,7 @@ import { LocalTaskComment, getLocalFileRawUrl } from '../../api';
import { MarkdownPreview } from '../files/FilePreview';
import { MarkdownText } from '../../lib/markdown-text';
import { ToolCallsSection, parseToolCallComment } from './ToolCallsSection';
import { parseJobRetry } from './movementHistory';
// We delegate spacing to MarkdownText's built-in COMPACT_PROSE default
// (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 }) {
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
const ackData = tryParseInterjectionAck(comment.body);
if (ackData) {

View 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();
});
});

View 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>
);
}

View 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 />}
</>
);
}

View File

@ -6,7 +6,7 @@ import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import { ChatPane } from './ChatPane';
import { __resetDraftPruneForTest } from '../../hooks/useDraft';
import type { LocalTask } from '../../api';
import type { LocalTask, LocalTaskComment } from '../../api';
const DRAFT_KEY = 'maestro:draft:v1:chat:42';
@ -71,4 +71,56 @@ describe('ChatPane drafts', () => {
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
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();
});
});

View 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();
});
});

View File

@ -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 { LocalTask, LocalTaskComment } from '../../api';
import { ChatMessage } from './ChatMessage';
import { isThinkingComment, hasTrailingThinking } from './thinkingUtils';
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 { useDraft } from '../../hooks/useDraft';
import { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract';
import { supportsFieldSizing, autosizeTextarea } from '../../lib/composerAutosize';
import { toBase64 } from '../../lib/fileAttachments';
import { ChatComposer } from './ChatComposer';
import { ChatMessageFeed } from './ChatMessageFeed';
import { MovementMap } from './MovementMap';
import { useMovementHistory } from '../../hooks/useTaskDetail';
interface ChatPaneProps {
task: LocalTask;
@ -32,234 +23,12 @@ interface ChatPaneProps {
export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activeDetailTab, onSelectDetailTab }: ChatPaneProps) {
const { t } = useTranslation('chat');
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 { 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 isWaitingSubtasks = jobStatus === 'waiting_subtasks';
const canInterject = jobStatus === 'running' || jobStatus === 'waiting_subtasks';
const inputLocked = jobStatus === 'dispatching';
// A job is already accepted but not yet running. The task is NOT idle: a new
// 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;
const jobStream = useJobStream(task.id, jobStatus);
const movementHistory = useMovementHistory(task.id, isBusy);
const historyEvents = movementHistory.data ?? [];
return (
<div className="relative flex flex-col h-full overflow-hidden">
@ -269,7 +38,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
<ChatPetOverlay
taskId={task.id}
taskStatus={task.latestJob?.status ?? null}
currentActivity={task.latestJob?.currentActivity ?? null}
lastToolEvent={jobStream.lastToolEvent}
workerId={task.latestJob?.workerId ?? null}
lastBackendId={task.latestJob?.lastBackendId ?? null}
/>
@ -339,269 +108,11 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
</div>
{/* Messages */}
<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>
)}
{/* 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">&times;</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 className="relative flex min-h-0 flex-1">
<ChatMessageFeed task={task} comments={comments} jobStream={jobStream} historyEvents={historyEvents} />
<MovementMap task={task} comments={comments} historyEvents={historyEvents} />
</div>
<ChatComposer task={task} commentsLength={comments.length} onSubmit={onSubmit} onCancel={onCancel} />
</div>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -1,10 +1,11 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LocalTaskComment } from '../../api';
import { LocalTaskComment, MovementHistoryEvent } from '../../api';
import { ChatMessage } from './ChatMessage';
import { isThinkingComment } from './thinkingUtils';
import { MarkdownText } from '../../lib/markdown-text';
import { ToolCallsSection, parseToolCallComment, type ToolCallData } from './ToolCallsSection';
import { parseJobRetry } from './movementHistory';
interface ProgressData {
movement: string;
@ -43,6 +44,7 @@ function getThinkingText(c: LocalTaskComment): string | null {
export type ChatItem =
| { type: 'comment'; comment: LocalTaskComment }
| { type: 'diagnostic'; comment: LocalTaskComment }
| { type: 'movement'; movementName: string; summary: ProgressData; inner: LocalTaskComment[]; completionComment: LocalTaskComment };
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';
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)!;
items.push({
type: 'movement',
@ -114,16 +122,18 @@ interface MovementGroupExpandedProps {
imageBaseUrl?: string;
/** 共有ビューで添付チップinput/ 配下・共有 API 非配信を隠す。ChatMessage に委譲。 */
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 [expanded, setExpanded] = useState(false);
const { movementName, summary, inner } = item;
const previewText = getPreviewText(item);
const retryEvents = historyEvents;
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 */}
<button
onClick={() => setExpanded(!expanded)}
@ -154,6 +164,17 @@ export function MovementGroupExpanded({ item, taskId, animatingIdx, startIdx, im
</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
tool_call comments are merged into one ToolCallsSection so the

View 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();
});
});

View 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,
)}
</>
);
}

View 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();
});
});

View 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>
);
}

View 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();
});
});

View 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>
);
}

View 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);
});
});

View 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 };
}

View 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);
});
});

View 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 };
};

View File

@ -58,4 +58,16 @@ describe('CreateTaskDialog outside-click safety', () => {
await user.click(screen.getByLabelText(/閉じる|close/i));
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();
});
});

View 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();
});
});

View File

@ -2,10 +2,9 @@ import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import * as Dialog from '@radix-ui/react-dialog';
import { useQuery } from '@tanstack/react-query';
import { CreateLocalTaskInput, fetchMyOrgs, Visibility, listBrowserSessionProfiles } from '../../api';
import { AttachmentDropzone } from './AttachmentDropzone';
import { PromptCoachPanel } from './PromptCoachPanel';
import { ScheduleFields } from './ScheduleFields';
import { CreateLocalTaskInput, fetchMyOrgs, Visibility, listBrowserSessionProfiles, fetchLlmWorkers } from '../../api';
import { CreateTaskDialogAdvancedSection } from './CreateTaskDialogAdvancedSection';
import { CreateTaskDialogCoreSection } from './CreateTaskDialogCoreSection';
import { usePieceList } from '../../hooks/usePieces';
import { useSpaces } from '../../hooks/useSpaces';
import { sortSpacesForRail } from '../../lib/spaceSort';
@ -31,9 +30,10 @@ interface CreateTaskDialogProps {
*
*/
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 }: CreateTaskDialogProps) {
export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody, placeholder, initialSpaceId, inlineContainer }: CreateTaskDialogProps) {
const { t } = useTranslation('create');
const { data: pieces } = usePieceList();
const { data: spacesData } = useSpaces();
@ -46,6 +46,8 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
staleTime: 60 * 1000,
});
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 }
const { data: connections } = useQuery({
queryKey: ['mcp-connections'],
@ -85,6 +87,8 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
askPolicy: 'low',
priority: 'medium',
workspaceMode: 'persistent',
llmWorkerId: null,
llmEffort: null,
});
// スペース起点で開いた場合は固定。それ以外はユーザーが任意で選択(既定=個人)。
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 [attachments, setAttachments] = useState<Array<{ name: string; contentBase64: string }>>([]);
const [browserSessionProfileId, setBrowserSessionProfileId] = useState<number | null>(null);
const [mcpDisabled, setMcpDisabled] = useState(false);
const [skillsDisabled, setSkillsDisabled] = useState(false);
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
@ -169,9 +171,6 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
onClose();
return;
}
const options: Record<string, boolean> = {};
if (mcpDisabled) options.mcpDisabled = true;
if (skillsDisabled) options.skillsDisabled = true;
const submitForm = {
...form,
// initialPiece が指定されているヘルプアシスタント等は piece を固定。
@ -184,7 +183,6 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
browserSessionProfileId: browserSessionProfileId ?? undefined,
// 固定スペースを最優先、無ければ選択値。未指定なら個人スペースに解決される。
spaceId: initialSpaceId ?? selectedSpaceId ?? undefined,
...(Object.keys(options).length > 0 ? { options } : {}),
};
await onSubmit(submitForm, attachments);
clearDraft();
@ -199,12 +197,14 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
const dirty = form.body.trim().length > 0 || attachments.length > 0;
return (
<Dialog.Root open onOpenChange={(open) => { if (!open) onClose(); }}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-slate-900/50 z-30" />
<Dialog.Root open modal={!inlineContainer} onOpenChange={(open) => { if (!open) onClose(); }}>
<Dialog.Portal container={inlineContainer ?? undefined}>
{!inlineContainer && <Dialog.Overlay className="fixed inset-0 bg-slate-900/50 z-30" />}
<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"
style={{ maxWidth: 'min(860px, 92vw)', maxHeight: '88dvh' }}
className={inlineContainer
? '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 => {
e.preventDefault();
}}
@ -234,304 +234,47 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
</Dialog.Close>
</div>
<div className="flex flex-col gap-4">
{/* Textarea */}
<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 handleSubmit();
}
}}
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'))}
<CreateTaskDialogCoreSection
initialPiece={initialPiece}
placeholder={placeholder}
initialSpaceId={initialSpaceId}
fixedSpace={fixedSpace}
selectedSpaceId={selectedSpaceId}
setSelectedSpaceId={setSelectedSpaceId}
form={form}
setForm={setForm}
attachments={attachments}
setAttachments={setAttachments}
resolvedPieces={resolvedPieces}
missingMcp={missingMcp}
onSubmit={handleSubmit}
saveDraft={saveDraft}
sortedSpaces={sortedSpaces}
orgs={orgs}
visibility={visibility}
/>
</div>
{/* Workspace mode (永続/一時的) + スペース選択 */}
<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>
{/* スペース: 固定 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);
}}
<CreateTaskDialogAdvancedSection
form={form}
setForm={setForm}
resolvedPieces={resolvedPieces}
activeSessionProfiles={activeSessionProfiles}
enabledLlmWorkers={enabledLlmWorkers}
isScheduled={isScheduled}
setIsScheduled={setIsScheduled}
schedule={schedule}
setSchedule={setSchedule}
browserSessionProfileId={browserSessionProfileId}
setBrowserSessionProfileId={setBrowserSessionProfileId}
visibility={visibility}
setVisibility={setVisibility}
visibilityScopeOrgId={visibilityScopeOrgId}
setVisibilityScopeOrgId={setVisibilityScopeOrgId}
orgs={orgs}
showAdvanced={showAdvanced}
setShowAdvanced={setShowAdvanced}
/>
{/* 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>
{showAdvanced && (
<div className="mt-3 space-y-4 border border-slate-100 rounded-xl p-4 bg-slate-50/50">
{/* Row 1: Piece, Profile, Priority */}
<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}
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">
{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">
<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')}
@ -547,7 +290,6 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
</button>
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View 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>
);
}

View 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>
);
}

View File

@ -10,6 +10,8 @@ interface PromptCoachPanelProps {
piece?: string;
/** Inject the rewrite suggestion back into the textarea. */
onApplyRewrite: (text: string) => void;
/** Compact spacing for the chat composer popover. */
compact?: boolean;
}
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';
}
export function PromptCoachPanel({ body, piece, onApplyRewrite }: PromptCoachPanelProps) {
export function PromptCoachPanel({ body, piece, onApplyRewrite, compact = false }: PromptCoachPanelProps) {
const { t } = useTranslation('create');
const mutation = useMutation<PromptCoachResult, Error, void>({
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')}
</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>
{mutation.isError && (
@ -56,7 +58,7 @@ export function PromptCoachPanel({ body, piece, onApplyRewrite }: PromptCoachPan
)}
{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 */}
<div className="flex items-baseline gap-2">
<span className="text-xs font-bold text-slate-500">{t('coach.overall')}</span>

View 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();
});
});

View 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>
);
}

View 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} />;
}

View File

@ -1,12 +1,17 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useActivePet } from '../../hooks/useActivePet';
import { useNodeAnimationState } from '../../hooks/useNodeAnimationState';
import { useStableNodePromotion } from '../../hooks/useStableNodePromotion';
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 { 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;
function usePrefersReducedMotion(): boolean {
@ -26,14 +31,17 @@ function usePrefersReducedMotion(): boolean {
export function ChatPetOverlay({
taskId,
taskStatus,
currentActivity,
lastToolEvent,
workerId,
lastBackendId,
className,
}: {
taskId: number | 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;
/**
* 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
// — same precedence as useActivePet uses for sprite selection.
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);
// Promote an 'idle' base state to 'running' when the backing node is
// actively processing. Don't override informative states like
// 'dispatching', 'waiting', 'done', or 'error' — those carry signal
// that node.busy doesn't.
const baseState: PetRuntimeState = taskBaseState === 'idle' && nodeAnimState === 'running'
// that node.busy doesn't. Real task-status states stay immediate; only
// this node-derived promotion goes through the hysteresis above.
const baseState: PetRuntimeState = taskBaseState === 'idle' && stableNodeAnimState === 'running'
? 'running'
: taskBaseState;
const baseStateRef = useRef(baseState);
baseStateRef.current = baseState;
const reducedMotion = (data?.settings.reducedMotion ?? false) || prefersReducedMotion;
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
// settle to idle so the wave doesn't loop forever.
useEffect(() => {
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;
const timer = window.setTimeout(() => setDisplayState('idle'), DONE_FLOURISH_MS);
return () => window.clearTimeout(timer);
}, [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(() => {
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;
if (active !== 'running' && active !== 'runningAlt' && active !== 'dispatching') return;
setDisplayState('jumping');
const timer = window.setTimeout(() => {
const current = baseStateRef.current;
if (current === 'running' || current === 'runningAlt' || current === 'dispatching') {
setDisplayState(current);
}
// For other base states (done / error / idle / waiting) the dedicated
// effects above will have taken over; don't fight them here.
setJumping(true);
// U5: a later tool_use before this fires just pushes the end time out
// (new callId -> this effect re-runs, clears + reschedules) — it never
// toggles `jumping` off and back on, so the CSS animation on the outer
// wrapper never restarts.
if (jumpTimerRef.current != null) window.clearTimeout(jumpTimerRef.current);
jumpTimerRef.current = window.setTimeout(() => {
setJumping(false);
jumpTimerRef.current = null;
}, JUMP_DURATION_MS);
return () => window.clearTimeout(timer);
}, [currentActivity]);
const toolName = useMemo(
() => extractLatestToolName(currentActivity),
[currentActivity],
);
}, [lastToolEvent, reducedMotion]);
if (isLoading || !data?.settings.enabled || !data.pet) return null;
const reducedMotion = data.settings.reducedMotion || prefersReducedMotion;
return (
<div
className={className ? `chat-pet-overlay ${className}` : 'chat-pet-overlay'}
@ -117,8 +150,7 @@ export function ChatPetOverlay({
aria-hidden="true"
>
<ToolSpark
toolName={toolName}
activityKey={currentActivity}
event={lastToolEvent}
enabled={data.settings.toolSparkEnabled}
reducedMotion={reducedMotion}
/>
@ -131,6 +163,7 @@ export function ChatPetOverlay({
gridRows={data.gridRows}
framesPerRow={framesPerRow}
state={displayState}
jumping={jumping}
size={data.settings.size}
reducedMotion={reducedMotion}
/>

View 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%');
});
});

View File

@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { rowIndexForState, type PetRuntimeState } from '../../lib/pets/petState';
const STATE_FRAME_DURATION: Record<PetRuntimeState, string> = {
@ -20,6 +21,7 @@ export function PetSprite({
gridRows,
framesPerRow,
state,
jumping = false,
size,
reducedMotion,
}: {
@ -31,58 +33,107 @@ export function PetSprite({
gridRows: number | null;
framesPerRow: number[] | null;
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;
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',
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}`,
reducedMotion ? 'pet-sprite-reduced' : '',
].filter(Boolean).join(' ');
const useGridCrop = !!(imageUrl && gridCols && gridRows && gridCols > 0 && gridRows > 0);
const useFrameCrop = !useGridCrop && !!(imageUrl && frameWidth && frameHeight);
const useGridCrop = !!(usableImageUrl && gridCols && gridRows && gridCols > 0 && gridRows > 0);
const useFrameCrop = !useGridCrop && !!(usableImageUrl && frameWidth && frameHeight);
const stateRow = useGridCrop ? rowIndexForState(state, gridRows!) : 0;
const bgPosY = useGridCrop && gridRows! > 1
? `${(stateRow / (gridRows! - 1)) * 100}%`
: '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 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`
: 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 (
<div
className={className}
className={outerClassName}
style={{ width: size, height: size }}
aria-hidden="true"
title={name}
>
<div
className={poseClassName}
style={{
width: size,
height: size,
overflow: useGridCrop || useFrameCrop ? 'hidden' : undefined,
}}
aria-hidden="true"
title={name}
>
{imageUrl ? (
{usableImageUrl ? (
useGridCrop ? (
<div
className="pet-sprite-grid"
style={{
width: size,
height: size,
backgroundImage: `url(${imageUrl})`,
backgroundRepeat: 'repeat-x',
backgroundImage: `url(${usableImageUrl})`,
backgroundRepeat: 'no-repeat',
backgroundSize: `${gridCols! * 100}% ${gridRows! * 100}%`,
backgroundPositionY: bgPosY,
animation: cycleAnimation,
imageRendering: 'auto',
...Object.fromEntries(framePositions.map((position, index) => [
`--pet-frame-${index}-position`,
position,
])),
}}
/>
) : useFrameCrop ? (
<img
src={imageUrl}
src={usableImageUrl}
alt=""
draggable={false}
style={{
@ -96,7 +147,7 @@ export function PetSprite({
}}
/>
) : (
<img src={imageUrl} alt="" draggable={false} />
<img src={usableImageUrl} alt="" draggable={false} />
)
) : (
<div className="pet-sprite-fallback">
@ -105,5 +156,6 @@ export function PetSprite({
</div>
)}
</div>
</div>
);
}

View 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