Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
96ebab1
docs(assistente): o cartão de Alinhamento do Assistente
developmentconexus-ops Nov 11, 2026
2103040
docs(assistente): os achados do leitor no cartão de Alinhamento
developmentconexus-ops Nov 11, 2026
11c50ee
docs(assistente): as palavras do fundador, a frase do comando, a prov…
developmentconexus-ops Nov 11, 2026
07c7c77
docs(assistente): os achados do leitor na U12: o ajudante nunca bifur…
developmentconexus-ops Nov 11, 2026
1b6c23e
Merge remote-tracking branch 'origin/main' into docs/conexus-assistan…
developmentconexus-ops Nov 11, 2026
6e1ccb7
docs(assistente): a aprovação do cartão de Alinhamento do Assistente …
developmentconexus-ops Nov 11, 2026
7f715e3
fix(assistente): a máquina da conversa de outro molde é retomada com …
developmentconexus-ops Nov 11, 2026
6fd353f
feat(assistente): a frase de cada comando, para o Assistente e o Buil…
developmentconexus-ops Nov 12, 2026
cdf7674
feat(assistente): ferramentas em paralelo e a web do provedor com paí…
developmentconexus-ops Nov 12, 2026
da61576
feat(assistente): a linha Fontes embaixo da mensagem, com título e do…
developmentconexus-ops Nov 12, 2026
c8d2375
fix(assistente): o teste de layout das fontes em arquivo próprio, sem…
developmentconexus-ops Nov 12, 2026
5a3f0b2
feat(assistente): a lista de tarefas como estado, o dia da semana, as…
developmentconexus-ops Nov 12, 2026
cda7977
feat(conectores): a leitura diz linhas e bytes e a frase manda abrir …
developmentconexus-ops Nov 12, 2026
a480663
Merge origin/main
developmentconexus-ops Nov 12, 2026
757704a
feat(assistente): a prova e o placar, as 27 perguntas com a contagem …
developmentconexus-ops Nov 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/server/src/builder/agents/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ export function createAgentConversations(deps: Deps) {
const resolvedOf = resolverOf(runOf, registry)
const machines = createConversationMachines(
{
...{ ...deps.machine, log, hosts: PACKAGE_HOSTS },
...{ ...deps.machine, log, hosts: PACKAGE_HOSTS, otherTemplate: 'replace' },
marksOf: machineMarks(runOf, resolvedOf),
},
deps.machineTuning,
Expand Down
118 changes: 118 additions & 0 deletions apps/server/src/builder/assistant/assistant.scorecard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict'
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { TestContext } from 'node:test'
import { test } from 'node:test'
import { mercadoLivreFake, PADARIA_SOL_MATRIZ, soldBy } from '@conexus/connectors'
import type { MastraMessagePart } from '@mastra/core/agent-controller'
import { appsOf, linkMercadoLivre } from '../../hub/connectors/test-connections.ts'
import { endOf, messagesOf } from '../conversation/test-conversation.ts'
import { assisting, created, READ, reading, said, talking } from './test-assistant.ts'
import { file, pdf } from './test-files.ts'
import { REAL_MODEL, subscribed } from './test-real-model.ts'
import type { Question, Scorecard } from './test-scorecard.ts'
import { goalsSheet, judged, QUESTIONS, scoreOf } from './test-scorecard.ts'

/** The folder of a round's scorecards, outside the repository: one JSON per question. */
function roundFolder(t: TestContext): string {
const folder = mkdtempSync(join(tmpdir(), 'conexus-scorecard-'))
t.after(() => rmSync(folder, { recursive: true, force: true }))
return folder
}

test('o placar conta o turno: uma leitura repetida e uma ferramenta que falhou dão repeated 1 e errors 1, e o placar vai num JSON por pergunta fora do repositório', async (t) => {
const on = await assisting(t, [
[reading('Matriz')],
[reading('Matriz')],
[reading('Inexistente')],
[said('Pronto.')],
])
const url = await created(on.hub, on.ana, on.assistant, { connections: [on.matriz] })
const events = await (await talking(t, on.hub, on.ana, url)).say('Quantas empresas?')
assert.equal(endOf(events), 'complete')
const messages = await messagesOf(on.hub, on.ana, url)
const asked = messages.findLastIndex((message) => message.role === 'person')
const parts: MastraMessagePart[] = messages.slice(asked + 1).flatMap((message) => message.parts)
const card = scoreOf({ question: 1, parts, events, right: true })
assert.deepEqual(card, {
question: 1,
right: true,
calls: 3,
errors: 1,
repeated: 1,
firstStepTokens: card.firstStepTokens,
helpers: 0,
})
const folder = roundFolder(t)
writeFileSync(join(folder, 'pergunta-1.json'), JSON.stringify(card, null, 2))
const written: Scorecard = JSON.parse(readFileSync(join(folder, 'pergunta-1.json'), 'utf8'))
assert.deepEqual(written, card)
assert.deepEqual(readdirSync(folder), ['pergunta-1.json'])
})

test('cada pergunta tem os seus dados: as 27 do fundador, numeradas, cada uma com os dados que um dublê dá e o que torna a resposta certa', () => {
assert.deepEqual(
QUESTIONS.map((each) => each.n),
Array.from({ length: 27 }, (_, at) => at + 1),
)
for (const each of QUESTIONS) {
assert.ok(each.text.length > 0 && each.right.length > 0 && each.data.length > 0, String(each.n))
}
assert.deepEqual(
QUESTIONS.filter((each) => each.enters !== 'now').map((each) => [each.n, each.enters]),
[
[26, 'U7'],
[27, 'U9'],
],
)
assert.ok([...soldBy(PADARIA_SOL_MATRIZ, '2026-09').values()].length > 0)
assert.equal(goalsSheet().split('\n').length, 201)
assert.equal(goalsSheet().split('\n')[0]?.split(',').length, 3)
})

test('o modelo de verdade, a lista inteira: cada pergunta numa conversa nova, o placar com os tokens do primeiro passo, e a resposta errada só conta', {
timeout: 10_800_000,
}, async (t) => {
if (REAL_MODEL === undefined) {
t.skip(
'opt-in: set CONEXUS_EVAL_REAL_MODEL to a folder (and the credential switch the other real cases use); firstmate runs it and attaches the scorecard JSONs',
)
return
}
const ml = mercadoLivreFake()
const served = await ml.serve()
t.after(() => served.close())
const on = await subscribed(t, REAL_MODEL, 'claude', (config) => ({
...config,
connectorOrigins: new Map([['mercado-livre', served.origin]]),
connectorApps: appsOf(ml.app),
}))
const linked = await linkMercadoLivre(on.hub, on.ana, on.companyId)
assert.equal(linked.statusCode, 201, linked.body)
const loja = String(Object(linked.json()).connection.id)
const folder = roundFolder(t)
const truth = [...soldBy(PADARIA_SOL_MATRIZ, '2026-09').values()]
const sheet = file('metas.csv', 'text/csv', Buffer.from(goalsSheet()))
for (const each of QUESTIONS.filter((one) => one.enters === 'now')) {
const connections = each.bare === true ? [] : [on.matriz, loja]
const url = await created(on.hub, on.ana, on.assistant, { connections })
const files = attached(each, sheet)
const events = await (await talking(t, on.hub, on.ana, url)).say(each.text, files)
const messages = await messagesOf(on.hub, on.ana, url)
const asked = messages.findLastIndex((message) => message.role === 'person')
const parts: MastraMessagePart[] = messages.slice(asked + 1).flatMap((message) => message.parts)
const right =
endOf(events) === 'complete' && judged(each, parts, READ, each.september ? truth : [])
const card = scoreOf({ question: each.n, parts, events, right })
writeFileSync(join(folder, `pergunta-${each.n}.json`), JSON.stringify(card, null, 2))
}
assert.equal(readdirSync(folder).length, QUESTIONS.filter((one) => one.enters === 'now').length)
process.stdout.write(`placar em ${folder}\n`)
})

function attached(each: Question, sheet: ReturnType<typeof file>) {
if (each.data.includes('AQ')) return [pdf]
if (each.data.includes('PL')) return [sheet]
return []
}
19 changes: 19 additions & 0 deletions apps/server/src/builder/assistant/assistant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { setTimeout as sleep } from 'node:timers/promises'
import { call, plantCompany } from '../../hub/identity/test-hub.ts'
import type { Sent } from '../../platform/test-anthropic.ts'
import { approving } from '../agents/test-approval.ts'
import { TITLE } from '../controller-kit/public.ts'
import { endOf, holding, MODEL, sent, title } from '../conversation/test-conversation.ts'
import { sessionStateOf, tool } from '../conversation/test-questions.ts'
import { assisting, created, publishedApp, reading, said, talking } from './test-assistant.ts'
import { conversation, LONG } from './test-files.ts'

const OTHER_MODEL = 'anthropic/claude-haiku-4-5'

Expand Down Expand Up @@ -266,3 +268,20 @@ test('sem escrita: com uma escrita no kit e na biblioteca do Projeto, o Assisten
assert.ok(!asked.tools.some((name) => name.startsWith('tool_')), asked.tools.join())
assert.doesNotMatch(asked.system, /lancarPedido|Lançar pedido/)
})

test('o Observador com foco de negócio', async (t) => {
const on = await conversation(t, (_, index) => ({ kind: 'text', text: index < 2 ? LONG : 'Li.' }))
for (const question of ['Quais as regras?', 'E os prazos?', 'E as multas?'])
assert.equal(endOf(await on.say(question)), 'complete')
assert.ok(on.observers.length > 0, 'the third turn, past the threshold, was observed')
for (const observer of on.observers) {
assert.ok(observer.system.includes(TITLE.ask.observer), 'the title instruction stays')
assert.match(observer.system, /numbers with where they came from/)
assert.match(observer.system, /Do not observe `<current-task-list>` or `<task-list-update>`/)
}
assert.equal(
TITLE.app.observer.includes('business analyst'),
false,
'the Builder’s does not change',
)
})
22 changes: 9 additions & 13 deletions apps/server/src/builder/assistant/assistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '@conexus/hub-api'
import { Agent } from '@mastra/core/agent'
import type { RequestContext } from '@mastra/core/request-context'
import { TaskSignalProvider } from '@mastra/core/signals'
import type { PostgresStore } from '@mastra/pg'
import type { FastifyInstance } from 'fastify'
import type { Connectors, SessionScope } from '../../hub/connectors/public.ts'
Expand Down Expand Up @@ -39,6 +40,7 @@ import type { HandOffDoors } from './handoff.ts'
import { registerHandOff } from './handoff.ts'
import type { Speakers } from './instructions.ts'
import { assistantInstructions } from './instructions.ts'
import { maysOf } from './mays.ts'
import { assistantMemory } from './memory.ts'
import type { OpenDeps } from './open.ts'
import { admitted, asked, automationOf, openConversation } from './open.ts'
Expand Down Expand Up @@ -239,6 +241,7 @@ function assistantAgent(
const scope = await scopeOf(requestContext)
return scope === undefined ? undefined : speakersOf(deps, scope)
})
const maysFor = maysOf(deps.identity, speakersFor, automationRun)
const defaults = agentDefaults(queue, parts.turns)
// The person's files go to the conversation's machine last, once the account's pick has chosen the model.
const assistantDefaults = {
Expand All @@ -248,30 +251,21 @@ function assistantAgent(
return new Agent({
id: ASSISTANT_CONTROLLER,
name: 'Assistant',
instructions: assistantInstructions({ offeredOf, speakersOf: speakersFor }),
instructions: assistantInstructions({ offeredOf, speakersOf: speakersFor, maysOf: maysFor }),
model: ({ requestContext }) => sessionModel(requestContext),
tools: async ({ requestContext }) => {
const scope = await scopeOf(requestContext)
const chosen = await chosenOf(requestContext)
const speakers = await speakersFor(requestContext)
const byAutomation = await automationRun(requestContext)
const mayAutomate =
speakers !== undefined && deps.identity.allows(speakers.role, 'manageAutomations')
const mayNote =
speakers !== undefined &&
!byAutomation &&
deps.identity.allows(speakers.role, 'manageConnections')
const { byAutomation, automate, note } = await maysFor(requestContext)
return {
...toolsOf(sessionModel(requestContext)),
[ASSISTANT_TOOL.overview]: overview,
[ASSISTANT_TOOL.showFile]: showFile,
...(mayAutomate
? { [ASSISTANT_TOOL.proposeAutomation]: proposeAutomationTool(chosen) }
: {}),
...(automate ? { [ASSISTANT_TOOL.proposeAutomation]: proposeAutomationTool(chosen) } : {}),
...handOffOf(scope, proposals, byAutomation),
...(scope === undefined
? {}
: assistantConnectorTools(deps, { scope, chosen, savedAt, mayNote })),
: assistantConnectorTools(deps, { scope, chosen, savedAt, mayNote: note })),
}
},
skills: async ({ requestContext }) => [
Expand All @@ -281,6 +275,7 @@ function assistantAgent(
),
],
...assistantDefaults,
signals: [new TaskSignalProvider()],
memory: parts.memory,
})
}
Expand All @@ -302,6 +297,7 @@ export function createAssistant(deps: Deps) {
...deps.machine,
log: deps.log,
hosts: PACKAGE_HOSTS,
otherTemplate: 'resume',
marksOf: machineMarks(scopeOf),
readMedia: READ_MEDIA,
},
Expand Down
42 changes: 42 additions & 0 deletions apps/server/src/builder/assistant/context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { endOf, messagesOf } from '../conversation/test-conversation.ts'
import { tool } from '../conversation/test-questions.ts'
import { assisting, created, said, talking } from './test-assistant.ts'
import { conversation, LONG } from './test-files.ts'

const LIST = '<current-task-list'
const NOTE = tool('task_write', {
tasks: [{ content: 'Somar os pedidos', status: 'pending', activeForm: 'Somando os pedidos' }],
})

/** The conversation's messages, not the fixed text that names the block. */
function carries(call: { texts: string[] } | undefined) {
return call?.texts.some((text) => text.includes(LIST)) === true
}

test('a lista de tarefas como estado', async (t) => {
const on = await assisting(t, [[NOTE], [said('Pronto.')]])
const url = await created(on.hub, on.ana, on.assistant, { connections: [] })
assert.equal(
endOf(await (await talking(t, on.hub, on.ana, url)).say('Some os pedidos')),
'complete',
)
const stored = JSON.stringify(await messagesOf(on.hub, on.ana, url))
assert.equal(stored.includes(LIST), false, 'the state signal is not a message of the person')
assert.equal(carries(on.calls[0]), false, 'before the first note there is no list')
assert.equal(carries(on.calls.at(-1)), true, 'after task_write the request carries the list')

const long = await conversation(t, (_, index) => {
if (index === 0) return { kind: 'blocks', blocks: [NOTE] }
return { kind: 'text', text: index < 3 ? LONG : 'Li.' }
})
for (const question of ['Some os pedidos', 'E os prazos?', 'E as multas?', 'E o resto?'])
assert.equal(endOf(await long.say(question)), 'complete')
assert.ok(long.observers.length > 0, 'the conversation was observed')
assert.equal(
carries(long.calls.at(-1)),
true,
'after an observation the list stays in the request',
)
})
13 changes: 12 additions & 1 deletion apps/server/src/builder/assistant/file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { BRUNO, CARLA, call, codeOf, memberOn } from '../../hub/identity/test-hu
import { TEST_ORIGIN } from '../../platform/test-database.ts'
import { endOf } from '../conversation/test-conversation.ts'
import { tool } from '../conversation/test-questions.ts'
import { INSTALLATION } from '../machine/test-machine.ts'
import { INSTALLATION, OLD_TEMPLATE } from '../machine/test-machine.ts'
import { assisting, created, said, talking } from './test-assistant.ts'

const FILE_TURN = [[write('relatorio.csv', 'a;b\n1;2\n')], [said('Escrevi.')]]
Expand Down Expand Up @@ -157,6 +157,17 @@ test('o download quando a lista do provedor falha: nenhuma máquina é ligada ne
assert.equal(got.body, 'a;b\n1;2\n', 'the machine the turn wrote on, with its file')
})

test('o arquivo de uma máquina de outro molde baixa', async (t) => {
const on = await withFile(t)
const { control } = on.hub.assistantMachines
const name = `conexus-${INSTALLATION}-${on.url.slice(on.url.lastIndexOf('/') + 1)}`
control.templates[name] = OLD_TEMPLATE
const got = await call(on.hub, on.ana, 'GET', fileOf(on.url, 'relatorio.csv'))
assert.equal(got.statusCode, 200, got.body)
assert.equal(got.body, 'a;b\n1;2\n', 'the file of the machine made from another template')
assert.deepEqual(control.replaced, [], 'no machine was killed')
})

test('o download cuja pausa falha: o arquivo lido é entregue, e a pausa que falhou fica no log (F27)', async (t) => {
const on = await withFile(t)
const { control } = on.hub.assistantMachines
Expand Down
Loading
Loading